Azure Static Web Apps: Custom Roles mit rolesSource und Hono

AzureHonoTypeScript

Das Problem

Azure Static Web Apps (SWA) mit Easy Auth liefert standardmäßig nur zwei Built-in-Rollen: anonymous und authenticated. Wer routenbasierte Zugriffskontrolle über staticwebapp.config.json machen will, stößt schnell an eine Grenze — denn Entra ID App-Rollen wie admin oder user tauchen nicht automatisch in userRoles auf.

{
  "routes": [
    { "route": "/api/*", "allowedRoles": ["admin"] }
  ]
}

Das hier funktioniert out-of-the-box nicht. SWA kennt admin nicht, weil es die Entra ID App-Rollen schlicht ignoriert. Jeder authentifizierte Benutzer hat dieselben Berechtigungen — egal welche Rollen in der App Registration definiert sind.

Die Lösung: rolesSource

SWA bietet einen wenig dokumentierten Mechanismus namens rolesSource. Die Idee: Beim Login ruft SWA intern einen API-Endpoint auf, der ein Array von Rollen zurückgibt. Diese werden dem clientPrincipal hinzugefügt und stehen danach in userRoles zur Verfügung.

So funktioniert der Flow

Benutzer loggt sich ein
  → SWA authentifiziert via Entra ID
  → SWA ruft intern POST /api/roles auf
  → Endpoint gibt { roles: ["admin", "user"] } zurück
  → SWA fügt Rollen zum clientPrincipal hinzu
  → userRoles enthält jetzt: ["anonymous", "authenticated", "admin", "user"]

Die Konfiguration

In staticwebapp.config.json wird rolesSource unter auth definiert:

{
  "auth": {
    "rolesSource": "/api/roles",
    "identityProviders": {
      "azureActiveDirectory": {
        "registration": {
          "openIdIssuer": "https://login.microsoftonline.com/{tenant-id}/v2.0",
          "clientIdSettingName": "AZURE_CLIENT_ID"
        }
      }
    }
  }
}

Der Endpoint

SWA schickt beim Login einen POST-Request an den konfigurierten Endpoint. Der Body sieht so aus:

{
  "identityProvider": "aad",
  "userId": "abc123...",
  "userDetails": "max.mustermann@example.com",
  "claims": [
    { "typ": "roles", "val": "admin" },
    { "typ": "roles", "val": "user" },
    { "typ": "http://schemas.microsoft.com/identity/claims/objectidentifier", "val": "..." }
  ]
}

Die Entra ID App-Rollen stecken als Claims mit typ: "roles" im Array. Der Endpoint muss sie extrahieren und als { roles: string[] } zurückgeben.

In unserem Fall mit Hono auf Azure Functions:

export function registerRolesRoutes(app: OpenAPIHono) {
  app.post('/roles', async (c) => {
    try {
      const body = await c.req.json();
      const claims: Array<{ typ: string; val: string }> = body?.claims ?? [];
      const roles = claims
        .filter((claim) => claim.typ === 'roles')
        .map((claim) => claim.val);
 
      return c.json({ roles });
    } catch {
      return c.json({ roles: [] });
    }
  });
}

Die Fallstricke

Hier wird es interessant. In der Microsoft-Dokumentation steht das alles irgendwo, aber die Kombination der Stolperfallen hat uns einige Stunden Debugging gekostet.

1. Kein clientPrincipal-Wrapper

Das ist der größte Stolperstein. Wer mit SWA Easy Auth arbeitet, kennt das clientPrincipal-Objekt aus dem x-ms-client-principal-Header. Man erwartet also, dass der rolesSource-Request dasselbe Format hat:

// Das erwartet man
{
  "clientPrincipal": {
    "identityProvider": "aad",
    "userId": "...",
    "claims": [...]
  }
}
 
// Das kommt tatsächlich
{
  "identityProvider": "aad",
  "userId": "...",
  "claims": [...]
}

SWA sendet die Felder direkt im Body-Root, ohne Wrapper. Wer auf body.clientPrincipal.claims zugreift, bekommt undefined — und damit keine Rollen.

2. Route-Reihenfolge in staticwebapp.config.json

SWA wertet Routen von oben nach unten aus. Der rolesSource-Aufruf passiert während des Login-Flows — der Benutzer ist zu diesem Zeitpunkt noch nicht authentifiziert. SWA sendet den Request als internen, unauthentifizierten Aufruf.

Wenn /api/* vor /api/roles steht und authenticated oder eine Custom Role erfordert, wird der rolesSource-Request blockiert:

// Falsch: /api/* fängt den Request ab
{
  "routes": [
    { "route": "/api/*", "allowedRoles": ["authenticated"] },
    { "route": "/api/roles", "allowedRoles": ["anonymous"] }
  ]
}
 
// Richtig: /api/roles steht VOR dem Catch-all
{
  "routes": [
    { "route": "/api/roles", "allowedRoles": ["anonymous"] },
    { "route": "/api/*", "allowedRoles": ["authenticated"] }
  ]
}

Das Heimtückische: Es gibt keine Fehlermeldung. Der Login funktioniert trotzdem — nur die Custom Roles fehlen still und leise im Token.

3. @hono/zod-openapi ist inkompatibel

Wer Hono mit @hono/zod-openapi für typsichere API-Routen nutzt, wird versuchen, den rolesSource-Endpoint als OpenAPI-Route zu definieren:

// Funktioniert nicht
const route = createRoute({
  method: 'post',
  path: '/roles',
  request: {
    body: {
      content: { 'application/json': { schema: rolesRequestSchema } }
    }
  },
  responses: { 200: { ... } }
});

Der SWA-interne Request besteht die zod-openapi-Validierung nicht. Ob es am Content-Type-Header liegt, an der Body-Struktur oder an etwas anderem — der Request kommt nie beim Handler an.

Die Lösung: Einen plain Hono-Handler mit app.post() verwenden. Keine Schema-Validierung, kein OpenAPI. Der Endpoint ist ohnehin nur intern von SWA erreichbar.

4. Von außen nicht testbar

Der rolesSource-Endpoint ist nur während des SWA-Login-Flows erreichbar. Wer versucht, ihn direkt per curl oder Postman aufzurufen, bekommt 404 — nicht 401, nicht 403, sondern 404. SWA routet externe Requests gar nicht erst zum Backend.

Das macht Debugging schwieriger. Um zu prüfen ob der Endpoint funktioniert, muss man sich tatsächlich einloggen und danach die Rollen im clientPrincipal inspizieren (z.B. über /.auth/me).

5. Fehlerfall = stille Degradation

Wenn der rolesSource-Endpoint einen Fehler wirft oder ein ungültiges Format zurückgibt, schlägt der Login nicht fehl. Der Benutzer wird trotzdem eingeloggt — nur ohne Custom Roles. Er hat dann lediglich anonymous und authenticated.

Deshalb gibt unser Endpoint im Fehlerfall ein leeres Array zurück anstatt einen 500er zu werfen:

catch {
  return c.json({ roles: [] });
}

Das ist bewusstes Verhalten: Lieber einloggen ohne Custom Roles als den Login komplett blockieren.

Voraussetzung: Entra ID App-Rollen

Damit das Ganze funktioniert, müssen die Rollen natürlich in der Entra ID App Registration existieren:

  1. App Registration → App roles → Rolle erstellen (z.B. admin, Value: admin)
  2. Enterprise Application → Users and groups → Benutzer/Gruppen der Rolle zuweisen
  3. Die App Registration muss die SWA sein, die auch in staticwebapp.config.json als Identity Provider konfiguriert ist

Zusammenfassung

| Thema | Detail | |---|---| | Konfiguration | auth.rolesSource: "/api/roles" in staticwebapp.config.json | | Payload | Felder direkt im Body, kein clientPrincipal-Wrapper | | Rollen-Claims | claims-Array filtern nach typ: "roles" | | Route-Reihenfolge | /api/roles mit anonymous vor /api/* | | Framework | Plain app.post(), kein zod-openapi | | Testbarkeit | Nur über den echten Login-Flow, nicht direkt aufrufbar | | Fehlerverhalten | Stille Degradation — Login klappt, Rollen fehlen |

Die Microsoft-Dokumentation beschreibt den Mechanismus, aber die Kombination aus fehlendem Wrapper, Route-Reihenfolge und Framework-Inkompatibilität findet man erst durch Trial and Error heraus. Hoffentlich spart dieser Artikel dem nächsten Entwickler ein paar Stunden Debugging.


Quellen

All Articles