<!--
OAUTH CALLBACK POLICY
This policy implements the callback endpoint for PKCE OAuth2 flow with Entra ID.
-->
<policies>
<inbound>
<base />
<!-- STEP 1: Extract the authorization code and state from Entra ID callback -->
<set-variable name="authCode" value="@((string)context.Request.Url.Query.GetValueOrDefault("code", ""))" />
<set-variable name="clientState" value="@{
string stateValue = (string)context.Request.Url.Query.GetValueOrDefault("state", "");
return !string.IsNullOrEmpty(stateValue) ? System.Net.WebUtility.UrlDecode(stateValue) : "";
}" />
<set-variable name="sessionState" value="@((string)context.Request.Url.Query.GetValueOrDefault("session_state", ""))" />
<!-- Validate required OAuth parameters -->
<choose>
<when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("authCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("clientState", "")))">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errorResponse = new JObject();
errorResponse["error"] = "invalid_request";
errorResponse["error_description"] = "Missing required OAuth callback parameters";
return errorResponse.ToString();
}</set-body>
</return-response>
</when>
</choose>
<!-- STEP 1.5: Validate that the state matches what the user consented to -->
<set-variable name="consent_state_valid" value="@{
try {
string returnedState = context.Variables.GetValueOrDefault<string>("clientState", "");
if (string.IsNullOrEmpty(returnedState)) {
return false;
}
// Extract consent state from cookie
var cookieHeader = context.Request.Headers.GetValueOrDefault("Cookie", "");
if (string.IsNullOrEmpty(cookieHeader)) {
return false;
}
string cookieName = "__Host-MCP_CONSENT_STATE";
string[] cookies = cookieHeader.Split(';');
foreach (string cookie in cookies) {
string trimmedCookie = cookie.Trim();
if (trimmedCookie.StartsWith(cookieName + "=")) {
string cookieValue = trimmedCookie.Substring(cookieName.Length + 1);
string decodedValue = System.Text.Encoding.UTF8.GetString(
System.Convert.FromBase64String(cookieValue));
JObject consentData = JObject.Parse(decodedValue);
string consentedState = consentData["state"]?.ToString();
// Constant-time comparison to prevent timing attacks
if (string.IsNullOrEmpty(consentedState) || returnedState.Length != consentedState.Length) {
return false;
}
int result = 0;
for (int i = 0; i < returnedState.Length; i++) {
result |= returnedState[i] ^ consentedState[i];
}
return (result == 0);
}
}
return false;
} catch (Exception ex) {
return false;
}
}" />
<!-- Validate consent state cookie -->
<choose>
<when condition="@(!context.Variables.GetValueOrDefault<bool>("consent_state_valid"))">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errorResponse = new JObject();
errorResponse["error"] = "invalid_state";
errorResponse["error_description"] = "State parameter does not match consented state.";
return errorResponse.ToString();
}</set-body>
</return-response>
</when>
</choose>
<!-- Clear the consent state cookie since it's been validated -->
<set-variable name="clear_consent_cookie" value="__Host-MCP_CONSENT_STATE=; Max-Age=0; Path=/; Secure; HttpOnly; SameSite=Lax" />
<!-- STEP 2: Retrieve stored PKCE code verifier using the client state parameter -->
<cache-lookup-value key="@("CodeVerifier-"+context.Variables.GetValueOrDefault("clientState", ""))" variable-name="codeVerifier" />
<!-- Validate that code verifier was found in cache -->
<choose>
<when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("codeVerifier", "")))">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errorResponse = new JObject();
errorResponse["error"] = "invalid_request";
errorResponse["error_description"] = "Authorization session expired or invalid state parameter";
return errorResponse.ToString();
}</set-body>
</return-response>
</when>
</choose>
<!-- STEP 3: Set token request parameters -->
<set-variable name="codeChallengeMethod" value="S256" />
<set-variable name="redirectUri" value="{{OAuthCallbackUri}}" />
<set-variable name="clientId" value="{{EntraIDClientId}}" />
<set-variable name="clientAssertionType" value="@(System.Net.WebUtility.UrlEncode("urn:ietf:params:oauth:client-assertion-type:jwt-bearer"))" />
<authentication-managed-identity resource="api://AzureADTokenExchange" client-id="{{EntraIDFicClientId}}" output-token-variable-name="ficToken"/>
<!-- STEP 4: Configure token request to Entra ID -->
<set-method>POST</set-method>
<set-header name="Content-Type" exists-action="override">
<value>application/x-www-form-urlencoded</value>
</set-header>
<set-body>@{
return $"client_id={context.Variables.GetValueOrDefault("clientId")}&grant_type=authorization_code&code={context.Variables.GetValueOrDefault("authCode")}&redirect_uri={context.Variables.GetValueOrDefault("redirectUri")}&scope=User.Read&code_verifier={context.Variables.GetValueOrDefault("codeVerifier")}&client_assertion_type={context.Variables.GetValueOrDefault("clientAssertionType")}&client_assertion={context.Variables.GetValueOrDefault("ficToken")}";
}</set-body>
<rewrite-uri template="/token" />
</inbound>
<backend>
<base />
</backend> <outbound>
<base />
<!-- STEP 5: Process the token response from Entra ID -->
<trace source="apim-policy">
<message>@("Token response received: " + context.Response.Body.As<string>(preserveContent: true))</message>
</trace>
<!-- Check if the response is successful (200 OK) and contains a token -->
<choose>
<when condition="@(context.Response.StatusCode != 200 || string.IsNullOrEmpty(context.Response.Body.As<JObject>(preserveContent: true)["access_token"]?.ToString()))">
<return-response>
<set-status code="@(context.Response.StatusCode)" reason="@(context.Response.StatusReason)" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errorResponse = new JObject();
errorResponse["error"] = "token_error";
errorResponse["error_description"] = "Failed to retrieve access token from Entra ID.";
return errorResponse.ToString();
}</set-body>
</return-response>
</when>
</choose>
<!-- STEP 6: Generate secure session token for MCP client -->
<set-variable name="IV" value="{{EncryptionIV}}" />
<set-variable name="key" value="{{EncryptionKey}}" />
<set-variable name="sessionId" value="@((string)Guid.NewGuid().ToString().Replace("-", ""))" />
<set-variable name="encryptedSessionKey" value="@{
// Generate a unique session ID
string sessionId = (string)context.Variables.GetValueOrDefault("sessionId");
byte[] sessionIdBytes = Encoding.UTF8.GetBytes(sessionId);
// Encrypt the session ID using AES
byte[] IV = Convert.FromBase64String((string)context.Variables["IV"]);
byte[] key = Convert.FromBase64String((string)context.Variables["key"]);
byte[] encryptedBytes = sessionIdBytes.Encrypt("Aes", key, IV);
return Convert.ToBase64String(encryptedBytes);
}" />
<!-- STEP 6: Lookup MCP client redirect URI stored during authorization -->
<cache-lookup-value key="@((string)context.Variables.GetValueOrDefault("clientState"))" variable-name="mcpConfirmConsentCode" />
<cache-lookup-value key="@($"McpClientAuthData-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")" variable-name="mcpClientData" />
<!-- Validate that MCP client data was found in cache -->
<choose>
<when condition="@(string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpConfirmConsentCode", "")) || string.IsNullOrEmpty((string)context.Variables.GetValueOrDefault("mcpClientData", "")))">
<return-response>
<set-status code="400" reason="Bad Request" />
<set-header name="Content-Type" exists-action="override">
<value>application/json</value>
</set-header>
<set-body>@{
var errorResponse = new JObject();
errorResponse["error"] = "invalid_request";
errorResponse["error_description"] = "MCP client authorization session expired or invalid";
return errorResponse.ToString();
}</set-body>
</return-response>
</when>
</choose>
<!-- STEP 8: Use the client's original state parameter directly -->
<set-variable name="mcpState" value="@(context.Variables.GetValueOrDefault<string>("clientState"))" />
<!-- STEP 9: Extract the stored mcp client callback redirect uri from cache -->
<set-variable name="callbackRedirectUri" value="@{
var mcpAuthDataAsJObject = JObject.Parse((string)context.Variables["mcpClientData"]);
return mcpAuthDataAsJObject["mcpCallbackRedirectUri"];
}" />
<!-- STEP 10: Store the encrypted session key and Entra token in cache -->
<!-- Store the encrypted session key with the MCP confirmation code as key -->
<cache-store-value duration="3600"
key="@($"AccessToken-{context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}")"
value="@($"{context.Variables.GetValueOrDefault("encryptedSessionKey")}")" />
<!-- Store the Entra token for later use -->
<cache-store-value duration="3600"
key="@($"EntraToken-{context.Variables.GetValueOrDefault("sessionId")}")"
value="@(context.Response.Body.As<JObject>(preserveContent: true).ToString())" />
<!-- STEP 11: Redirect back to MCP client with confirmation code -->
<return-response>
<set-status code="302" reason="Found" />
<set-header name="Location" exists-action="override">
<value>@($"{context.Variables.GetValueOrDefault("callbackRedirectUri")}?code={context.Variables.GetValueOrDefault("mcpConfirmConsentCode")}&state={System.Net.WebUtility.UrlEncode((string)context.Variables.GetValueOrDefault("mcpState"))}")</value>
</set-header>
<!-- Clear the consent state cookie -->
<set-header name="Set-Cookie" exists-action="append">
<value>@(context.Variables.GetValueOrDefault<string>("clear_consent_cookie"))</value>
</set-header>
<set-body />
</return-response>
</outbound>
<on-error>
<base />
</on-error>
</policies>