azure-functionsazure-ad-b2copenid-connectazure-ad-b2c-custom-policypkce

Why is my AD B2C custom policy returning invalid_grant after adding an orchestration step that calls an Azure Function?


I have a SignIn policy and user journey that works with the authorization code flow. However, when I add an orchestration step that accepts a query string value and sends it to an Azure function, the /token call after /authorize returns HTTP 400 with this body:

{
    "error": "invalid_grant",
    "error_description": "AADB2C90085: The service has encountered an internal error. Please reauthenticate and try again."
}

I'm testing this in two ways:

  1. A React SPA with msal-browser-3.11.1 that uses authorization code flow. Nothing is changing in the auth config between policy changes.
  2. The Azure Identity Experience Framework portal's "Run now endpoint" using a dummy app that does not use Authorization code flow. The reply URL is https://jwt.ms/

The pre-modified policy works with both of these.

The modified policy works just fine when using the "Run now endpoint" provided in the Identity Experience Framework portal as long as the selected application does not use the authorization code flow. However, when using my React SPA with authorization code flow, the /token breaks.

Note: The below code samples use placeholder values

The original policy:

<TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" 
    PolicySchemaVersion="0.3.0.0" 
    TenantId="placeholder.onmicrosoft.com" 
    PolicyId="b2c_1a_signin" 
    PublicPolicyUri="http://placeholder.onmicrosoft.com/b2c_1a_signin" 
    TenantObjectId="my tenant id">
    <BasePolicy>
        <TenantId>placeholder.onmicrosoft.com</TenantId>
        <PolicyId>B2C_1A_TrustFrameworkExtensions</PolicyId>
    </BasePolicy>
    <RelyingParty>
        <DefaultUserJourney ReferenceId="SignIn" />
        <Endpoints>
            <!--points to refresh token journey when app makes refresh token request-->
            <Endpoint Id="Token" UserJourneyReferenceId="RedeemRefreshToken" />
        </Endpoints>
        <TechnicalProfile Id="PolicyProfile">
            <DisplayName>PolicyProfile</DisplayName>
            <Protocol Name="OpenIdConnect" />
            <OutputClaims>
                <OutputClaim ClaimTypeReferenceId="displayName" />
                <OutputClaim ClaimTypeReferenceId="givenName" />
                <OutputClaim ClaimTypeReferenceId="surname" />
                <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" />
                <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" />
            </OutputClaims>
            <SubjectNamingInfo ClaimType="sub" />
        </TechnicalProfile>
    </RelyingParty>
</TrustFrameworkPolicy>

The original user journey:

<UserJourney Id="SignIn">
  <OrchestrationSteps>
    <OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
      <ClaimsProviderSelections>
        <ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
      </ClaimsProviderSelections>
      <ClaimsExchanges>
        <ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
      </ClaimsExchanges>
    </OrchestrationStep>
    <!-- This step reads any user attributes that we may not have received when in the token. -->
    <OrchestrationStep Order="2" Type="ClaimsExchange">
      <ClaimsExchanges>
        <ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
      </ClaimsExchanges>
    </OrchestrationStep>
    <OrchestrationStep Order="3" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
  </OrchestrationSteps>
  <ClientDefinition ReferenceId="DefaultWeb" />
</UserJourney>

The modified policy:

<TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" 
    PolicySchemaVersion="0.3.0.0" 
    TenantId="placeholder.onmicrosoft.com" 
    PolicyId="b2c_1a_signin" 
    PublicPolicyUri="http://placeholder.onmicrosoft.com/b2c_1a_signin" 
    TenantObjectId="my tenant id">
    <BasePolicy>
        <TenantId>placeholder.onmicrosoft.com</TenantId>
        <PolicyId>B2C_1A_TrustFrameworkExtensions</PolicyId>
    </BasePolicy>
    <RelyingParty>
        <DefaultUserJourney ReferenceId="SignIn" />
        <Endpoints>
            <!--points to refresh token journey when app makes refresh token request-->
            <Endpoint Id="Token" UserJourneyReferenceId="RedeemRefreshToken" />
        </Endpoints>
        <TechnicalProfile Id="PolicyProfile">
            <DisplayName>PolicyProfile</DisplayName>
            <Protocol Name="OpenIdConnect" />
            <OutputClaims>
                <OutputClaim ClaimTypeReferenceId="displayName" />
                <OutputClaim ClaimTypeReferenceId="givenName" />
                <OutputClaim ClaimTypeReferenceId="surname" />
                <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" />
                <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" />
                <OutputClaim ClaimTypeReferenceId="qsValue" DefaultValue="{OAUTH-KV:qsValue}" />
            </OutputClaims>
            <SubjectNamingInfo ClaimType="sub" />
        </TechnicalProfile>
    </RelyingParty>
</TrustFrameworkPolicy>

The modified user journey:

<UserJourney Id="SignIn">
  <OrchestrationSteps>
    <OrchestrationStep Order="1" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
      <ClaimsProviderSelections>
        <ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
      </ClaimsProviderSelections>
      <ClaimsExchanges>
        <ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
      </ClaimsExchanges>
    </OrchestrationStep>
    <!-- This step reads any user attributes that we may not have received when in the token. -->
    <OrchestrationStep Order="2" Type="ClaimsExchange">
      <ClaimsExchanges>
        <ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
      </ClaimsExchanges>
    </OrchestrationStep>
    <OrchestrationStep Order="3" Type="ClaimsExchange">
      <ClaimsExchanges>
        <ClaimsExchange Id="CallMyAzureFunction" TechnicalProfileReferenceId="AzureFunction"></ClaimsExchange>
      </ClaimsExchanges>
    </OrchestrationStep>
    <OrchestrationStep Order="4" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
  </OrchestrationSteps>
  <ClientDefinition ReferenceId="DefaultWeb" />
</UserJourney>

The new claims provider which calls the Azure Function:

<ClaimsProvider>
  <DisplayName>Claims</DisplayName>
  <TechnicalProfiles>
    <TechnicalProfile Id="AzureFunction">
      <DisplayName>Calls an Azure Function to check whether the query string value is valid</DisplayName>
      <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
      <Metadata>
        <Item Key="ServiceUrl">Azure Function Service URL placeholder</Item>
        <Item Key="AuthenticationType">None</Item>
        <Item Key="SendClaimsIn">Body</Item>
        <Item Key="AllowInsecureAuthInProduction">true</Item>
      </Metadata>
      <InputClaims>
        <InputClaim Required="true" ClaimTypeReferenceId="objectId" />
        <InputClaim ClaimTypeReferenceId="qsValue" DefaultValue="{OAUTH-KV:qsValue}" />
      </InputClaims>
      <OutputClaims>
        <OutputClaim ClaimTypeReferenceId="qsValue" />
      </OutputClaims>
      <UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
    </TechnicalProfile>
  </TechnicalProfiles>
</ClaimsProvider>

The claim type for the query string parameter:

<ClaimType Id="qsValue">
  <DisplayName>Query String Value</DisplayName>
  <DataType>string</DataType>
  <UserHelpText>A value passed in the query string</UserHelpText>
</ClaimType>

Here is the trace from Application Insights for a failed token call made from the React SPA. It's the only application insight for the correlation id.

[
  {
    "Kind": "Headers",
    "Content": {
      "UserJourneyRecorderEndpoint": "urn:journeyrecorder:applicationinsights",
      "CorrelationId": "3cf51ffc-0093-468e-bda1-cc3022e5883b",
      "EventInstance": "Event:TOKEN",
      "TenantId": "placeholder.onmicrosoft.com",
      "PolicyId": "B2C_1A_signin"
    }
  },
  {
    "Kind": "Transition",
    "Content": {
      "EventName": "TOKEN",
      "StateName": "Initial"
    }
  },
  {
    "Kind": "Predicate",
    "Content": "Web.TPEngine.StateMachineHandlers.IsTokenExchangeValidRequest"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true,
      "Statebag": {
        "MACHSTATE": {
          "c": "2024-04-03T16:14:01.8426102Z",
          "k": "MACHSTATE",
          "v": "Initial",
          "p": true
        },
        "JC": {
          "c": "2024-04-03T16:14:01.8416127Z",
          "k": "JC",
          "v": "en",
          "p": true
        },
        "ComplexItems": "_MachineEventQ, TCTX"
      },
      "PredicateResult": "True"
    }
  },
  {
    "Kind": "Predicate",
    "Content": "Web.TPEngine.StateMachineHandlers.IsTokenExchangeOnBehalfOfFlow"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true,
      "PredicateResult": "False"
    }
  },
  {
    "Kind": "Predicate",
    "Content": "Web.TPEngine.StateMachineHandlers.IsTokenExchangeCustomClientCredentialsFlow"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true,
      "PredicateResult": "False"
    }
  },
  {
    "Kind": "Predicate",
    "Content": "Web.TPEngine.StateMachineHandlers.IsTokenExchangeResourceOwnerFlow"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true,
      "PredicateResult": "False"
    }
  },
  {
    "Kind": "Predicate",
    "Content": "Web.TPEngine.StateMachineHandlers.ShouldTokenExchangeRunAsUserJourneyHandler"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true,
      "PredicateResult": "False"
    }
  },
  {
    "Kind": "Action",
    "Content": "Web.TPEngine.StateMachineHandlers.TokenExchangeHandler"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": false,
      "Statebag": {
        "Complex-CLMS": {
          "tenantId": "tenant id"
        },
        "CI": {
          "c": "2024-04-03T16:14:01.8546106Z",
          "k": "CI",
          "v": "bf57499d-ca05-4a27-a281-dcd9b32c3e15",
          "p": true
        },
        "CT": {
          "c": "2024-04-03T16:14:01.9176155Z",
          "k": "CT",
          "v": "Spa",
          "p": true
        },
        "ComplexItems": "_MachineEventQ, TCTX, REPRM, AUPRM, PRMCH"
      }
    }
  },
  {
    "Kind": "Action",
    "Content": "Web.TPEngine.StateMachineHandlers.TransactionEndHandler"
  },
  {
    "Kind": "HandlerResult",
    "Content": {
      "Result": true
    }
  }
]

Solution

  • I realized my mistake after reading this blog post: https://blog.wojtek.pro/aad-b2c-quick-tips-query-string-parameters/

    The issue wasn't the call to the Azure function, it was how I was passing the query string parameter value around.

    I added a claims provider that grabs the query string parameter instead of relying on {OAUTH-KV:qsValue} everywhere. Then I added a claims exchange orchestration step which calls the said claims provider and placed it as the first orchestration step in my SignIn user journey. The qsValue parameter now pulls from from the query string and persists throughout the journey just as I wanted.

    Here is what my ending configuration looks like:

    The claim type for the query string parameter:

    <ClaimType Id="qsValue">
      <DisplayName>Query String Value</DisplayName>
      <DataType>string</DataType>
      <UserHelpText>A value passed in the query string</UserHelpText>
    </ClaimType>
    

    The new claims provider that gets the query string parameter:

    <ClaimsProvider>
     <DisplayName>Get query string value</DisplayName>
      <TechnicalProfiles>
        <TechnicalProfile Id="GetValueFromQueryString">
           <DisplayName>Populate query string value claim with parameter value</DisplayName>
           <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.ClaimsTransformationProtocolProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
           <Metadata>
             <Item Key="IncludeClaimResolvingInClaimsHandling">true</Item>
           </Metadata>
           <OutputClaims>
             <OutputClaim ClaimTypeReferenceId="qsValue" DefaultValue="{OAUTH-KV:qsValue}" AlwaysUseDefaultValue="true"/>
          </OutputClaims>
        </TechnicalProfile>
      </TechnicalProfiles>
    </ClaimsProvider>
    

    The claims provider that calls the Azure Function:

    <ClaimsProvider>
      <DisplayName>Claims</DisplayName>
      <TechnicalProfiles>
        <TechnicalProfile Id="AzureFunction">
          <DisplayName>Calls an Azure Function to check whether the query string value is valid</DisplayName>
          <Protocol Name="Proprietary" Handler="Web.TPEngine.Providers.RestfulProvider, Web.TPEngine, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" />
          <Metadata>
            <Item Key="ServiceUrl">Azure Function Service URL placeholder</Item>
            <Item Key="AuthenticationType">None</Item>
            <Item Key="SendClaimsIn">Body</Item>
            <Item Key="AllowInsecureAuthInProduction">true</Item>
          </Metadata>
          <InputClaims>
            <InputClaim Required="true" ClaimTypeReferenceId="objectId" />
            <InputClaim ClaimTypeReferenceId="qsValue" />
          </InputClaims>
          <OutputClaims>
            <OutputClaim ClaimTypeReferenceId="qsValue" />
          </OutputClaims>
          <UseTechnicalProfileForSessionManagement ReferenceId="SM-Noop" />
        </TechnicalProfile>
      </TechnicalProfiles>
    </ClaimsProvider>
    

    The policy:

    <TrustFrameworkPolicy xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xmlns="http://schemas.microsoft.com/online/cpim/schemas/2013/06" 
        PolicySchemaVersion="0.3.0.0" 
        TenantId="placeholder.onmicrosoft.com" 
        PolicyId="b2c_1a_signin" 
        PublicPolicyUri="http://placeholder.onmicrosoft.com/b2c_1a_signin" 
        TenantObjectId="my tenant id">
        <BasePolicy>
            <TenantId>placeholder.onmicrosoft.com</TenantId>
            <PolicyId>B2C_1A_TrustFrameworkExtensions</PolicyId>
        </BasePolicy>
        <RelyingParty>
            <DefaultUserJourney ReferenceId="SignIn" />
            <Endpoints>
                <!--points to refresh token journey when app makes refresh token request-->
                <Endpoint Id="Token" UserJourneyReferenceId="RedeemRefreshToken" />
            </Endpoints>
            <TechnicalProfile Id="PolicyProfile">
                <DisplayName>PolicyProfile</DisplayName>
                <Protocol Name="OpenIdConnect" />
                <OutputClaims>
                    <OutputClaim ClaimTypeReferenceId="displayName" />
                    <OutputClaim ClaimTypeReferenceId="givenName" />
                    <OutputClaim ClaimTypeReferenceId="surname" />
                    <OutputClaim ClaimTypeReferenceId="objectId" PartnerClaimType="sub" />
                    <OutputClaim ClaimTypeReferenceId="tenantId" AlwaysUseDefaultValue="true" DefaultValue="{Policy:TenantObjectId}" />
                    <OutputClaim ClaimTypeReferenceId="qsValue" />
                </OutputClaims>
                <SubjectNamingInfo ClaimType="sub" />
            </TechnicalProfile>
        </RelyingParty>
    </TrustFrameworkPolicy>
    

    The user journey:

    <UserJourney Id="SignIn">
      <OrchestrationSteps>
        <OrchestrationStep Order="1" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="GetQueryStringValue" TechnicalProfileReferenceId="GetValueFromQueryString" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="2" Type="CombinedSignInAndSignUp" ContentDefinitionReferenceId="api.signuporsignin">
          <ClaimsProviderSelections>
            <ClaimsProviderSelection ValidationClaimsExchangeId="LocalAccountSigninEmailExchange" />
          </ClaimsProviderSelections>
          <ClaimsExchanges>
            <ClaimsExchange Id="LocalAccountSigninEmailExchange" TechnicalProfileReferenceId="SelfAsserted-LocalAccountSignin-Email" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <!-- This step reads any user attributes that we may not have received when in the token. -->
        <OrchestrationStep Order="3" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="AADUserReadWithObjectId" TechnicalProfileReferenceId="AAD-UserReadUsingObjectId" />
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="4" Type="ClaimsExchange">
          <ClaimsExchanges>
            <ClaimsExchange Id="CallMyAzureFunction" TechnicalProfileReferenceId="AzureFunction"></ClaimsExchange>
          </ClaimsExchanges>
        </OrchestrationStep>
        <OrchestrationStep Order="5" Type="SendClaims" CpimIssuerTechnicalProfileReferenceId="JwtIssuer" />
      </OrchestrationSteps>
      <ClientDefinition ReferenceId="DefaultWeb" />
    </UserJourney>