Tuesday, 21 April 2026

Putting asurewebsites.net behind a Cloudflare reverse proxy, including OAuth2

The Challenge

Azure App Service is multi-tenant; it uses the Host header to route requests. Normally, if you point a domain at Azure without adding it as a "Custom Domain," Azure rejects the request (404/503). Furthermore, authentication flows (OIDC) and persistent connections (WebSockets) break because they rely on matching domains and protocols across the proxy.

The Solution

1. The Reverse Proxy (Cloudflare Worker)

Deploy a Worker to act as an "Application Gateway."

Header Swapping: The Worker intercepts requests to the public domain, changes the Host header to the Azure fqdn so Azure accepts the traffic, but preserves the original domain in X-Forwarded-Host.

Response Rewriting: Then implement logic to catch Location headers (redirects) and Set-Cookie attributes. This ensures that any internal Azure URLs are swapped back to your public domain before reaching the user's browser.

2. Microsoft Entra ID (OAuth2/OIDC) Support

Authentication often breaks behind proxies due to "Correlation failed" errors. It's fixed by:

Updating Redirect URIs: Registered the public domain callback in the Entra ID App Registration.

Cookie Domain Correction: The Worker was configured to dynamically rewrite the Domain= attribute of Azure’s affinity and session cookies to match the public domain.

Cookie Policy: In ASP.NET, force SameSite=None and Secure=Always to ensure browsers send security tokens back through the proxy.

3. SignalR & WebSocket Integration

Standard Workers block WebSockets unless explicitly handled.

Detect Upgrades: Intercept the Upgrade: websocket header.

Handshake Preservation: Use the Worker’s fetch API specifically to tunnel the handshake while maintaining the correct Host and Forwarded headers.

Infrastructure Alignment: Enable the "Web Sockets" toggle in the Azure App Service configuration.

4. ASP.NET Core Middleware Tuning

Reconfigure the .NET pipeline to "trust" the Worker:

Forwarded Headers: Use app.UseForwardedHeaders() at the very top of Program.cs. This allows the app to correctly determine that the user is on HTTPS and on the custom domain, preventing infinite redirect loops.

Middleware Order: Place UseForwardedHeaders before UseAuthentication so the login logic sees the correct request metadata.

Final Infrastructure Map

User Browser → ://{public domain} (HTTPS)

Cloudflare Worker → Rewrites Host to the azure fqdn

Azure App Service → Processes request (thinks it's local)

Cloudflare Worker → Rewrites Cookies/Redirects back to the public domain

User Browser → Seamlessly logged in and connected via WebSockets

The Code

This setup allows for a custom domain without the Azure Custom Domain requirement, while supporting Microsoft Entra ID and WebSockets.

1. Cloudflare Worker Gateway Script

This script acts as the "Application Gateway," rewriting headers to satisfy Azure’s routing while fixing cookies and redirects for the client.

javascript

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const PROXY_DOMAIN = url.hostname; // e.g., yourdomain.com
    const AZURE_URL = "somesite.azurewebsites.net";
    const TOKEN = "1309bb34-9b9b-45cc-80af-e2345da09d48";

    // Handle WebSocket upgrade requests
    const upgradeHeader = request.headers.get("Upgrade");
    if (upgradeHeader && upgradeHeader.toLowerCase() === "websocket") {
      // Point the URL to Azure
      url.hostname = AZURE_URL;
      
      // Create a new request for the handshake
      const wsRequest = new Request(url, request);
      wsRequest.headers.set("Host", AZURE_URL);
      wsRequest.headers.set("X-Forwarded-Host", PROXY_DOMAIN);
      wsRequest.headers.set("X-Forwarded-Proto", "https");

      // Use fetch with the 'webSocket' property for the upgrade
      return fetch(wsRequest); 
    }

    // Prepare request headers (mimic App Gateway behavior)
    const newRequestHeaders = new Headers(request.headers);
    
    // Set forwarded headers so .NET can see the original user info
    newRequestHeaders.set("X-Forwarded-Host", PROXY_DOMAIN);
    newRequestHeaders.set("X-Forwarded-Proto", "https");
    newRequestHeaders.set("X-Proxy-Token", TOKEN);
    
    // Azure App Service needs this to identify your app
    newRequestHeaders.set("Host", AZURE_URL);

    // Forward request to Azure
    url.hostname = AZURE_URL;
    const modifiedRequest = new Request(url, {
      method: request.method,
      headers: newRequestHeaders,
      body: request.body,
      redirect: "manual", 
    });

    let response = await fetch(modifiedRequest);

    // Prepare response headers (rewrite phase)
    let newResponseHeaders = new Headers(response.headers);

    // Rewrite all Set-Cookie domains to your public domain
    const setCookies = newResponseHeaders.getAll("Set-Cookie");
    newResponseHeaders.delete("Set-Cookie"); // We will re-add them corrected

    for (let cookie of setCookies) {
      // Replace the internal Azure domain with your public proxy domain
      const escapedAzureUrl = AZURE_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
      let correctedCookie = cookie.replace(new RegExp(`Domain=${escapedAzureUrl}`, "gi"), `Domain=${PROXY_DOMAIN}`);
      
      // Ensure 'secure' and 'samesite=none' are present for OIDC
      if (!correctedCookie.toLowerCase().includes("secure")) correctedCookie += "; Secure";
      if (!correctedCookie.toLowerCase().includes("samesite")) correctedCookie += "; SameSite=None";
      
      newResponseHeaders.append("Set-Cookie", correctedCookie);
    }

    // Rewrite the 'Location' header for redirects (OAuth/EntraID)
    const location = newResponseHeaders.get("Location");
    if (location && location.includes(AZURE_URL)) {
      newResponseHeaders.set("Location", location.replace(AZURE_URL, PROXY_DOMAIN));
    }

    // Mask internal Azure headers (App Gateway style)
    newResponseHeaders.delete("Server"); // Hide "Microsoft-IIS/10.0"
    newResponseHeaders.delete("X-Powered-By"); // Hide "ASP.NET"
    newResponseHeaders.delete("X-AspNet-Version");

    return new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers: newResponseHeaders,
    });
  },

};


2. ASP.NET Core Program.cs Configuration

The following dotnet config ensures the application trusts the Worker headers and correctly handles OIDC "Correlation" cookies.

csharp

builder.Services.Configure<ForwardedHeadersOptions>(options =>

{

options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost;

// Clear known networks/proxies to allow the Cloudflare Worker IPs

options.KnownNetworks.Clear();

options.KnownProxies.Clear();

});

builder.Services.Configure<CookiePolicyOptions>(options =>

{

// Essential for OIDC behind proxies

options.MinimumSameSitePolicy = SameSiteMode.None;

options.Secure = CookieSecurePolicy.Always;

});

authBuilder.AddMicrosoftIdentityWebApp( options =>

{

builder.Configuration.Bind("AzureAd", options);


options.Events.OnRedirectToIdentityProvider = context => {


var request = context.HttpContext.Request;

var forwardedHost = request.Headers["X-Forwarded-Host"].FirstOrDefault();

var forwardedProto = request.Headers["X-Forwarded-Proto"].FirstOrDefault();


if (!string.IsNullOrEmpty(forwardedHost) && !string.IsNullOrEmpty(forwardedProto))

{

var redirectUri = $"{forwardedProto}://{forwardedHost}{request.PathBase}/signin-oidc";

context.ProtocolMessage.RedirectUri = redirectUri;

}

// else: use default


return Task.CompletedTask;

};

})

app.UseForwardedHeaders();


3. Key Infrastructure Requirements

Azure Portal: Navigate to your App Service > Configuration > General Settings and set Web sockets to On.

Cloudflare Dashboard: Navigate to Network and ensure WebSockets is Enabled.

SSL/TLS: Cloudflare encryption mode must be set to Full or Full (Strict).

Entra ID: Add the public domain to your App Registration > Authentication > Redirect URIs.


No comments:

Post a Comment