4 min read
Azure AD B2C Custom Policies: Advanced Identity Flows
Azure AD B2C provides customer identity and access management (CIAM) for your applications. While User Flows handle common scenarios, Custom Policies (Identity Experience Framework) enable complex identity journeys. Let’s explore how to build them.
User Flows vs Custom Policies
User Flows: Pre-built, configurable through portal
- Sign-up/sign-in
- Password reset
- Profile editing
Custom Policies: XML-based, fully customizable
- Custom claims
- External API integration
- Complex branching logic
- Custom validation
Custom Policy Structure
Custom policies use XML with these components:
<?xml version="1.0" encoding="UTF-8"?>
<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_SignUpSignIn"
PublicPolicyUri="http://yourtenant.onmicrosoft.com/B2C_1A_SignUpSignIn">
<BasePolicy>
<TenantId>yourtenant.onmicrosoft.com</TenantId>
<PolicyId>B2C_1A_TrustFrameworkExtensions</PolicyId>
</BasePolicy>
<!-- BuildingBlocks, ClaimsProviders, UserJourneys, RelyingParty -->
</TrustFrameworkPolicy>
Claims Schema
Define the claims your policy uses:
<BuildingBlocks>
<ClaimsSchema>
<!-- Built-in claims -->
<ClaimType Id="email">
<DisplayName>Email Address</DisplayName>
<DataType>string</DataType>
<UserInputType>TextBox</UserInputType>
<Restriction>
<Pattern RegularExpression="^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" />
</Restriction>
</ClaimType>
<!-- Custom claims -->
<ClaimType Id="loyaltyNumber">
<DisplayName>Loyalty Number</DisplayName>
<DataType>string</DataType>
<UserHelpText>Your loyalty program member number</UserHelpText>
<UserInputType>TextBox</UserInputType>
</ClaimType>
<ClaimType Id="accountTier">
<DisplayName>Account Tier</DisplayName>
<DataType>string</DataType>
<UserInputType>Readonly</UserInputType>
</ClaimType>
<ClaimType Id="riskScore">
<DisplayName>Risk Score</DisplayName>
<DataType>int</DataType>
</ClaimType>
</ClaimsSchema>
</BuildingBlocks>
Claims Providers
Define how claims are collected or validated:
Self-Asserted Claims Provider
<ClaimsProvider>
<DisplayName>Local Account SignIn</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="SelfAsserted-LocalAccountSignin">
<DisplayName>Local Account Signin</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.SelfAssertedAttributeProvider" />
<Metadata>
<Item Key="SignUpTarget">SignUpWithLogonEmailExchange</Item>
<Item Key="setting.operatingMode">Email</Item>
<Item Key="ContentDefinitionReferenceId">api.localaccountsignin</Item>
</Metadata>
<InputClaims>
<InputClaim ClaimTypeReferenceId="signInName" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="signInName" Required="true" />
<OutputClaim ClaimTypeReferenceId="password" Required="true" />
<OutputClaim ClaimTypeReferenceId="objectId" />
<OutputClaim ClaimTypeReferenceId="authenticationSource" />
</OutputClaims>
<ValidationTechnicalProfiles>
<ValidationTechnicalProfile ReferenceId="login-NonInteractive" />
</ValidationTechnicalProfiles>
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
REST API Claims Provider
<ClaimsProvider>
<DisplayName>REST API Claims Provider</DisplayName>
<TechnicalProfiles>
<TechnicalProfile Id="REST-ValidateLoyaltyNumber">
<DisplayName>Validate Loyalty Number</DisplayName>
<Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider" />
<Metadata>
<Item Key="ServiceUrl">https://api.yourservice.com/validate-loyalty</Item>
<Item Key="AuthenticationType">Bearer</Item>
<Item Key="SendClaimsIn">Body</Item>
<Item Key="AllowInsecureAuthInProduction">false</Item>
</Metadata>
<CryptographicKeys>
<Key Id="BearerAuthenticationToken" StorageReferenceId="B2C_1A_RestApiToken" />
</CryptographicKeys>
<InputClaims>
<InputClaim ClaimTypeReferenceId="loyaltyNumber" />
<InputClaim ClaimTypeReferenceId="email" />
</InputClaims>
<OutputClaims>
<OutputClaim ClaimTypeReferenceId="accountTier" />
<OutputClaim ClaimTypeReferenceId="loyaltyPoints" />
</OutputClaims>
<UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
</TechnicalProfile>
</TechnicalProfiles>
</ClaimsProvider>
User Journeys
Define the flow of steps:
<UserJourneys>
<UserJourney Id="SignUpOrSignInWithLoyalty">
<OrchestrationSteps>
<!-- Step 1: Sign-in or sign-up -->
<OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
<ClaimsProviderSelections>
<ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
<ClaimsProviderSelection TargetClaimsExchangeId="GoogleExchange" />
</ClaimsProviderSelections>
<ClaimsExchanges>
<ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 2: Social IDP -->
<OrchestrationStep Order="2" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>objectId</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="GoogleExchange" TechnicalProfileReferenceId="Google-OAuth2" />
<ClaimsExchange Id="SignUpWithLogonEmailExchange" TechnicalProfileReferenceId="LocalAccountSignUpWithLogonEmail" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 3: Collect loyalty number for new users -->
<OrchestrationStep Order="3" Type="ClaimsExchange">
<Preconditions>
<Precondition Type="ClaimsExist" ExecuteActionsIf="true">
<Value>loyaltyNumber</Value>
<Action>SkipThisOrchestrationStep</Action>
</Precondition>
</Preconditions>
<ClaimsExchanges>
<ClaimsExchange Id="CollectLoyaltyNumber" TechnicalProfileReferenceId="SelfAsserted-CollectLoyalty" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 4: Validate loyalty with API -->
<OrchestrationStep Order="4" Type="ClaimsExchange">
<ClaimsExchanges>
<ClaimsExchange Id="ValidateLoyalty" TechnicalProfileReferenceId="REST-ValidateLoyaltyNumber" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 5: Read user from directory -->
<OrchestrationStep Order="5" Type="ClaimsExchange">
<ClaimsExchanges>
<ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
</ClaimsExchanges>
</OrchestrationStep>
<!-- Step 6: Issue token -->
<OrchestrationStep Order="6" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
</OrchestrationSteps>
</UserJourney>
</UserJourneys>
REST API Integration (Backend)
from flask import Flask, request, jsonify
from functools import wraps
import jwt
app = Flask(__name__)
def validate_b2c_token(f):
"""Validate B2C service-to-service token"""
@wraps(f)
def decorated(*args, **kwargs):
token = request.headers.get('Authorization', '').replace('Bearer ', '')
if not token:
return jsonify({'error': 'No token provided'}), 401
try:
# Validate token (simplified - use proper validation in production)
decoded = jwt.decode(token, options={"verify_signature": False})
request.claims = decoded
except Exception as e:
return jsonify({'error': 'Invalid token'}), 401
return f(*args, **kwargs)
return decorated
@app.route('/validate-loyalty', methods=['POST'])
@validate_b2c_token
def validate_loyalty():
"""Validate loyalty number and return account tier"""
data = request.json
loyalty_number = data.get('loyaltyNumber')
email = data.get('email')
# Lookup loyalty information
loyalty_info = lookup_loyalty(loyalty_number, email)
if loyalty_info:
return jsonify({
'accountTier': loyalty_info['tier'],
'loyaltyPoints': loyalty_info['points']
})
else:
# Return error to B2C
return jsonify({
'version': '1.0.0',
'status': 400,
'userMessage': 'Invalid loyalty number. Please check and try again.'
}), 400
@app.route('/risk-assessment', methods=['POST'])
@validate_b2c_token
def risk_assessment():
"""Assess sign-in risk"""
data = request.json
risk_score = calculate_risk(
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent'),
email=data.get('email')
)
response = {
'riskScore': risk_score,
'requireMfa': risk_score > 50
}
if risk_score > 80:
# Block high-risk sign-ins
return jsonify({
'version': '1.0.0',
'status': 400,
'userMessage': 'Sign-in blocked due to suspicious activity.'
}), 400
return jsonify(response)
def lookup_loyalty(number, email):
# Database lookup
pass
def calculate_risk(ip_address, user_agent, email):
# Risk calculation logic
return 25
Custom UI
Define content definitions for custom branding:
<BuildingBlocks>
<ContentDefinitions>
<ContentDefinition Id="api.signuporsignin">
<LoadUri>https://yourbrand.blob.core.windows.net/b2c-ui/signin.html</LoadUri>
<RecoveryUri>~/common/default_page_error.html</RecoveryUri>
<DataUri>urn:com:microsoft:aad:b2c:elements:contract:unifiedssp:2.1.0</DataUri>
</ContentDefinition>
</ContentDefinitions>
</BuildingBlocks>
Custom HTML template:
<!DOCTYPE html>
<html>
<head>
<title>Sign In - Your Brand</title>
<link href="https://yourbrand.com/styles/b2c.css" rel="stylesheet">
</head>
<body>
<div class="container">
<header>
<img src="https://yourbrand.com/logo.png" alt="Logo">
</header>
<main>
<div id="api">
<!-- B2C will inject content here -->
</div>
</main>
<footer>
<p>© 2021 Your Brand</p>
</footer>
</div>
</body>
</html>
Deploying Policies
# Upload policy files in order
az rest --method put \
--url "https://graph.microsoft.com/beta/trustFramework/policies/B2C_1A_TrustFrameworkBase/\$value" \
--headers "Content-Type=application/xml" \
--body @TrustFrameworkBase.xml
az rest --method put \
--url "https://graph.microsoft.com/beta/trustFramework/policies/B2C_1A_TrustFrameworkExtensions/\$value" \
--headers "Content-Type=application/xml" \
--body @TrustFrameworkExtensions.xml
az rest --method put \
--url "https://graph.microsoft.com/beta/trustFramework/policies/B2C_1A_SignUpSignIn/\$value" \
--headers "Content-Type=application/xml" \
--body @SignUpSignIn.xml