OIDC mit ASP.NET Core auf Azure App Container
16.08.2023, Christian Mäder

Azure Container Apps ermöglicht einfaches Container Deployment. Die Integration von ASP.NET Core-Anwendungen erfordert besondere Beachtung, wenn es um Authentifizierung mittels OIDC geht, denn Reverse Proxies führen zu Verzerrungen. ASP.NET Core kann angewiesen werden, den X-Forwarded-Proto Header auzuwerten, um korrekte OIDC Return-URLs zu generieren und Anwendungsfälle wie HTTP-zu-HTTPS-Weiterleitungen korrekt zu handhaben.

Azure Container Apps ist eine Plattform, um einfach Container zu deployen. Darunterliegend wird von Microsoft derzeit ein komplett verwaltetes Kubernetes verwendet. Davon bekommt man als Kund:in der Plattform nur wenig mit – das ist gewollt und meistens auch gut so.

Werden nun ASP.NET Core Applikationen als Container auf Azure Container Apps gehostet, müssen einige Besonderheiten bedacht werden. Dies kommt daher, dass Requests nicht mehr direkt vom Browser zur ASP.NET Core Applikation gelangen. Dieser Umstand muss unter anderem beachtet werden, wenn OIDC als Authentifizierungsprotokoll eingebunden wird.

Alte Welt: Direkte Verbindungen

Für Autorisierungen («Login») mit OIDC müssen Kund:innen an den OIDC-Provider weitergeleitet werden. Dabei muss die ASP.NET Core Applikation dem OIDC-Provider eine Return-URL mitteilen. Auf diese URL wird der Browser vom OIDC-Provider weitergeleitet, sobald sich die Person beim OIDC-Provider erfolgreich authentifiziert hat. Der URL werden Parameter angehängt, womit die ASP.NET Core Applikation die Identität der Kund:innen feststellen kann.

ASP.NET Core, bzw. der entsprechende Code der Microsoft Identity Platform, setzen diese OIDC Return-URL aus folgenden Angaben zusammen:

  • Dem Protokoll der HTTP(S) Anfrage, bspw. https.
  • Der Host-Header aus der HTTP(S) Anfrage, bspw. meine-app.com.
  • Dem konfigurierten Return-Path, bspw. /auth/success.

Mit den Beispielangaben würde die OIDC Return-URL also zu https://meine-app.com/auth/success zusammengesetzt. Dies funktioniert ohne Probleme, solange die Anfragen von einem Browser direkt zur ASP.NET Core Applikation gelangen.

Neue Welt: Reverse Proxies

Heute sind aber Setups von geschäftskritischen Applikationen typischerweise komplizierter. Anfragen gelangen vom Browser nicht mehr direkt zur ASP.NET Core Applikation. Das hat einerseits mit Sicherheitsbedenken zu tun. Es hat aber auch praktische Gründe. Ein CDN kann etwa die Zeit zum Largest Contentfull Paint merklich verringern, aber auch DDoS Angriffe effektiv abwehren.

Aber auch moderne Hosting-Platformen wie Azure Container Apps oder Kubernetes fangen Abfragen des Browsers zuerst ab und leiten diese intern weiter. Dies erlaubt etwa Rolling Deployments, wobei eine neue Version einer Applikation immer zunächst nur einem kleinen Kreis von Kund:innen zur Verfügung gestellt wird, bspw. 10 %. Erst mit der Zeit – und wenn keine Probleme auftreten – werden weitere Kund:innen auf die neue Version geführt.

Eine typische Architektur des Autors besteht aus der Kombination der folgenden Dienste:

  • Ein CDN wie Cloudflare (oder Azure Front Door) nimmt die Anfragen der Browser entgegen.
  • Die Anfragen werden zu Azure Container Apps durchgereicht.
  • Azure Container Apps leitet die Anfragen an den Container der ASP.NET Core Applikation durch.
Typische Architektur mit Cloudflare, Azure Container Apps und ASP.NET Core.

Verkettung von Services

Ein Request vom Browser zur Applikation passiert also mehrere Dienste:

  1. Browser → Cloudflare
  2. Cloudflare → Azure App Container Ingress
  3. Azure App Container Ingress → ASP.NET Core Container

All diese Dienste handeln als Reverse-Proxy. Das heisst, die nehmen die Anfrage des vorherigen Dienstes entgegen und starten ihrerseits eine neue Anfrage an den nachfolgenden Dienst. Ohne weitere Massnahme gehen so wichtige Informationen für die ASP.NET Core Applikation verloren, nämlich die ursprüngliche IP-Adresse des Browsers und auch mit welchem Protokoll (HTTP oder HTTPS) die ursprüngliche Verbindung durchgeführt wurde.

Denn die ASP.NET Core Applikation wird jeweils nur vom Azure App Container Ingress kontaktiert und spricht nie mit dem eigentlichen Browser. In unserem Setup spricht auch der Azure App Container Ingress nicht mit dem tatsächlichen Browser, sondern nur mit Cloudflare. Nur Cloudflare kennt die ursprüngliche IP des Browsers und weiss, welches Protokoll der Browser und Cloudflare miteinander sprechen.

Um damit umzugehen, hat sich ein De-Facto Standard herausgebildet. Die meisten Reverse-Proxies fügen jeweils ein X-Forwarded-For und ein X-Forwarded-Proto Header hinzu, wenn sie eine Verbindung zum nächsten Service starten. (Bei der Recherche hat der Autor vom neuen und in RFC-7239 standardisierten Forwarded Header gelesen. Dieser scheint jedoch erst eine geringe Verbreitung zu haben und wir in der Folge in diesem Artikel ignoriert.)

Beispielhaft funktioniert das so:

  1. Der Browser mit der IP 123.1.2.3 macht eine Anfrage per HTTPS an https://meine-app.com.
  2. Cloudflare ist für die Anfrage zuständig und nimmt diese entgegen. Cloudflare weiss, dass es diese Anfrage an https://meineapp.generiertername-abc1234.westeurope.azurecontainerapps.io weiterleiten muss. Dabei fügt Cloudflare der Anfrage unter anderem die beiden HTTP Header X-Forwarded-For: 123.1.2.3 und X-Forwarded-Proto: http hinzu. Die IP, mit der Cloudflare diese IP macht, ist in diesem Beispiel 104.22.33.123.
  3. Der Ingress Service von Azure App Container nimmt diese Anfrage entgegen. Der Service weiss, unter welcher internen IP der Container der ASP.NET Core Applikation läuft. Entsprechend leitet er diese Anfrage weiter, verändert jedoch den von Cloudflare hinzugefügten Header X-Forwarded-For, nämlich zu X-Forwarded-For: 123.1.2.3, 104.22.33.123. (Theoretisch sollte der Ingress auch einen Eintrag zum X-Forwarded-Proto Header hinzufügen, sodass dieser zu X-Forwarded-Proto: https, https wird. Das geschieht in der Praxis jedoch leider nicht. Hier könnte Microsoft nachbessern.) Der Ingress Service sendet diese Anfrage über eine Azure App Container-interne IP-Adresse, in diesem Beispiel ist dies die IP 172.70.123.45.

Zuletzt nimmt nun die ASP.NET Core Applikation die Anfrage entgegen. Die Applikation nimmt die Anfrage über den Port 80 entgegen – also ohne HTTPS. (Denn sie kann ohne Weiteres kein gültiges Zertifikat für die interne IP erhalten, womit HTTPS wegfällt.) Ohne die zusätzlich Header wüsste die ASP.NET Core Applikation jetzt also nur, dass sie über HTTP eine Anfrage von 127.70.123.45 über das HTTP Protokoll erhalten hat. Sie hätte keine Information darüber, dass dies nicht der eigentliche Browser ist und der eigentliche Browser mit seinem Gegenüber über HTTPS kommuniziert hat.

Herausforderung

Da der Request vom Azure Container Apps Ingress an den ASP.NET Core Container per HTTP zugestellt wird, würde ohne die X-Forwarded-* Header die OIDC Return-URL aus folgenden Angaben zusammengesetzt:

  • Dem Protokoll der Anfrage, also http.
  • Dem Wert aus dem Host-Header, bspw. meine-app.com.
  • Dem konfigurierten Return-Path, bspw. /auth/success.

In diesem Beispiel würde die OIDC Return-URL also http://meine-app.com/auth/success heissen, obwohl der Browser die Webseite tatsächlich mit https:// aufgerufen hat. Die meisten OIDC-Provider werden die Return-URL ohne https://-Präfix nicht als gültige Return-URL akzeptieren. Es ergeben sich zudem weitere Probleme, auf die hier nicht näher eingegangen wird.

Lösung

ASP.NET Core hat glücklicherweise die Funktion eingebaut, mit der die beiden Header X-Forwarded-For und X-Forwarded-Proto ausgewertet und berücksichtigt werden können. Dies ist für viele Anwendungen wichtig:

  • Rate-Limit für bestimmte IPs berechnen und durchsetzen.
  • Weiterleitung von HTTP zu HTTPS erzwingen.
  • Verhindern, dass ein Browser (bspw. aufgrund einer Fehlkonfiguration) direkt auf die Applikation zugreift, statt via Cloudflare zu gehen.
  • Ursprünglichen IP in einem Audit-Log aufführen.

Es erlaubt aber eben auch, dass die ASP.NET Core Applikation eine korrekte OIDC Return-URL generieren kann. Dazu muss folgende Konfiguration getätigt werden:

// Program.cs

using System.Text;
using Microsoft.AspNetCore.HttpOverrides;

var builder = WebApplication.CreateBuilder(args);

builder.Services.Configure<ForwardedHeadersOptions>(
    options =>
    {
        // Die Header müssen explizit ausgewählt werden
        options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;

        // Es gibt zwei Reverse Proxies zwischen dem Browser
        // und dem ASP.NET Core Container, nämlich
        // 1. CDN, bspw. Cloudflare oder Azure Front Door
        // 2. Azure Container Apps Ingress Service
        options.ForwardLimit = 2;

        // Header von Proxies aus allen Netzwerken werden berücksichtig.
        // Dies kann so gemacht werden, weil Azure Container Apps kein direkten
        // Zugriffe von aussen auf den ASP.NET Core Container möglich sind.
        options.KnownNetworks.Clear();
        options.KnownProxies.Clear();
    }
);

var app = builder.Build();

// Die Reihenfolge der `app.Use*()` spielt eine wichtige Rolle!
// `UseForwardedHeaders()` sollte als Erstes konfiguriert werden,
// unbedingt auch vor `UseHttpsRedirection()`, `UseHsts()` oder `UseCors()`!
app.UseForwardedHeaders();

app.MapGet(
    "/",
    context => context.Response.WriteAsync(
      $"Hello World to '{context.Connection.RemoteIpAddress}' via '{context.Request.Scheme}'",
      Encoding.UTF8
    )
);

app.Run();
return;

Mit dieser Konfiguration erstellt ASP.NET Core, bzw. die Microsoft Identity Platform, zuverlässig korrekt OIDC Return-Urls. Zudem kann jederzeit über den HTTP Context herausgefunden werden, wer den Request ursprünglich abgesetzt hat.

Weitere Informationen zur Konfiguration der X-Forwarded-*-Header in ASP.NET liefert der Artikel Configure ASP.NET Core to work with proxy servers and load balancers. Auch zum Ingress Service in Azure Container Apps gibt es weitere Informationen.