Skip to content

Cross-Domain Token Chaining — Walkthrough

Zweck dieses Dokuments

Dieser Walkthrough zeigt ein vollständiges End-to-End-Setup für Cross-Domain Token Chaining (RFC 8693) anhand einer realistischen Topologie mit zwei STS-Installationen und drei CMI-Mandanten. Er ergänzt die Operator-Referenz (TokenExchangeGrant.md) um ein konkretes, kopierfähiges Beispiel. Beide Sub-Varianten — Impersonation (ohne actor_token) und Delegation (mit actor_token und act-Claim) — werden parallel dokumentiert. Die Client-Authentifizierung am STS erfolgt in diesem Walkthrough durchgängig per Shared-Secret.

Inhaltsverzeichnis


1. Topologie und Szenario

Eine Angular-SPA authentifiziert den Endbenutzer am STS-A im Mandant T1 (Code-Flow + PKCE). Die SPA ruft anschliessend ein Backend-API (ASP.NET Core) auf und übergibt das T1-Access-Token im Authorization-Header.

Das API benötigt zwei zusätzliche Tokens, um nachgelagerte Services in den Mandanten T2 und T3 zu erreichen:

  • T2 ist ein zweiter Mandant auf derselben STS-Installation wie T1 — der Tokenwechsel findet als Cross-Tenant Token-Exchange auf STS-A statt (IssuerKind = InternalSts).
  • T3 liegt auf einer separaten STS-Installation (STS-B). STS-B vertraut STS-A als Partner-STS (IssuerKind = PartnerSts).
            ┌───────────┐
            │ Angular   │  (Code-Flow + PKCE auf STS-A/T1)
            └─────┬─────┘
                  │ Bearer T1-Token  (subject_token für die Token-Exchanges)
                  ▼
            ┌───────────┐                            ┌──────────────────────────┐
            │   API     │── (nur Delegation) ───────▶│ STS-A /t1 /connect/token │ → Actor-Token
            │ (.NET 9)  │   client_credentials        │ (eigene API-Auth)        │
            │           │                            └──────────────────────────┘
            │           │
            │           │── TokenExchange ──────────▶│ STS-A /t2 /connect/token │ → T2-Token (Cross-Tenant)
            │           │   subject_token (+actor)    │ (gleiche Installation)   │
            │           │                            └──────────────────────────┘
            │           │
            │           │── TokenExchange ──────────▶│ STS-B /t3 /connect/token │ → T3-Token (Partner-STS)
            │           │   subject_token (+actor)    │ (separate Installation)  │
            └───────────┘                            └──────────────────────────┘
                                                                ▲
                                                                │ vertraut STS-A
                                                                │ via TrustedPartnerStsIssuers

Hosts und Mandanten (Platzhalter, frei wählbar):

Komponente Wert
STS-A Host sts-a.example.com
STS-A Tenants t1, t2
STS-B Host sts-b.example.com
STS-B Tenant t3
API Host api.example.com
API Client-ID am STS api-backend
SPA Client-ID am STS spa-angular-t1
Audience T2-Backend t2-backend
Audience T3-Backend t3-backend

Die Token-Endpoints sind:

  • https://sts-a.example.com/t1/identity/connect/token (Login-Mandant + Actor-Token)
  • https://sts-a.example.com/t2/identity/connect/token (Cross-Tenant-Ziel)
  • https://sts-b.example.com/t3/identity/connect/token (Partner-STS-Ziel)

Erweiterte Topologie (volle RFC-Abdeckung)

Das obige Diagramm zeigt den Walkthrough-Fokus. Darüber hinaus kann jeder Mandant zusätzlich externe OIDC-IdPs (IssuerKind = OidcIdP) und weitere Partner-STSs als Subject-Token-Quellen konfigurieren. Pro Mandant wird ein eigener CMI Server angesprochen (CmiPrivateUri aus der Mandanten-Konfiguration, vgl. Backend-Aufruf). Resource Server prüfen das ausgestellte Token gegen die jwks_uri des ausstellenden STS — der STS gibt keine besonderen Garantien gegenüber dem Resource Server, alle Vertrauensbeziehungen laufen über die Discovery-Standardpfade.


2. Impersonation vs. Delegation

RFC 8693 §1.1 unterscheidet zwei Modi, die in CMI STS parallel verfügbar sind und sich pro Aufruf unterscheiden:

Aspekt Impersonation (Variante 1) Delegation (Variante 2)
Request-Parameter subject_token subject_token + actor_token
act-Claim im ausgestellten Token nein ja ({ "sub": "api-backend", "iss": "https://sts-a.example.com/t1/identity" })
Down-Stream sieht nur den Endbenutzer Endbenutzer und den Akteur
Audit-Footprint „User X handelte" „User X via API Y handelte"
Konfiguration am STS nur Subject-Allow-Listen zusätzlich TokenExchange.AllowDelegation=true, TokenExchange.AllowedActorIssuers
API-Aufrufe ein TokenExchange-Aufruf pro Ziel zusätzlicher client_credentials-Aufruf für das Actor-Token
may_act im Subject-Token wird ignoriert (sofern nicht gesetzt) muss zum Actor passen, sonst STS934/STS941

Empfehlung: Impersonation ist der einfachere Default. Delegation wird benötigt, wenn

  • der Down-Stream-Service den Akteur im Audit unterscheiden muss (z. B. „Aktion erfolgte via offizielle API, nicht via Direkt-Login"),
  • regulatorische Anforderungen die Chain-of-Custody verlangen,
  • das Subject-Token einen may_act-Claim trägt (siehe § 9),
  • mehrere Hops (Service A → Service B → Service C) nachvollziehbar bleiben sollen (act.act-Verschachtelung — der ursprüngliche Akteur bleibt unter act.act erhalten, jeder Hop fügt eine weitere Ebene davor ein).

In diesem Walkthrough ist die Konfiguration so geschrieben, dass beide Varianten parallel funktionieren. Wer ausschliesslich Impersonation einsetzt, kann die unter Variante 2 markierten Konfigurationszeilen entfernen.


3. End-to-End-Flow

1. Browser  → STS-A/t1   /connect/authorize          (Code-Flow + PKCE, Login bei IdP)
2. STS-A/t1 → Browser    Redirect mit code
3. Browser  → STS-A/t1   /connect/token              (code → T1-Token)
4. Browser  → API        Authorization: Bearer T1
5. API      → STS-A/t1   JwtBearer-Validation       (lokal via JWKS)
   ─── nur Variante 2 (Delegation) ───
6a. API     → STS-A/t1   /connect/token (client_credentials) → Actor-Token
   ───────────────────────────────────
7. API      → STS-A/t2   /connect/token (TokenExchange, subject + ggf. actor, audience=t2-backend)
                          ← T2-Token (act-Claim nur bei Delegation)
8. API      → t2-backend Authorization: Bearer T2  (CMI-Server-Aufruf im Mandant T2)
9. API      → STS-B/t3   /connect/token (TokenExchange, subject + ggf. actor, audience=t3-backend)
                          ← T3-Token (act-Claim nur bei Delegation)
10. API     → t3-backend Authorization: Bearer T3  (CMI-Server-Aufruf im Mandant T3)
11. API     → Browser    HTTP 200 mit aggregierter Antwort

Wichtig zur Reihenfolge: Schritt 6a (Actor-Token) wird nur einmal pro API-Request benötigt — das gleiche Actor-Token kann für die TokenExchanges in den Schritten 7 und 9 wiederverwendet werden, solange audience/scope zu beiden Zielen passt. Das API sollte Actor-Token-Werte bis kurz vor deren Ablauf cachen, um den client_credentials-Roundtrip nicht pro Endbenutzer-Request zu fahren.


4. Konfiguration STS-A (Mandanten T1 und T2)

STS-A bedient den Login-Mandanten T1 und den Cross-Tenant-Ziel-Mandanten T2. Die nachfolgende Konfiguration ist eine vollständige, in den CMI-STS-Tenant-Konfigurations-Loader einsetzbare Datei.

Sicherheitshinweis (Cross-Tenant, InternalSts): Der STS erkennt ein eigenes (Cross-Tenant-)Token daran, dass dessen iss zur Form {Origin}/{tenant}/identity passt. Origin wird standardmässig aus dem Request abgeleitet (scheme://host) — derselbe Mechanismus, mit dem der STS auch seinen eigenen Issuer bildet. Für den produktiven Betrieb mit Cross-Tenant wird dringend empfohlen, den Origin über den globalen Konfigurationsschlüssel InternalStsExpectedOrigin (z. B. "https://sts-a.example.com") fest zu pinnen. Ist der Origin nicht gepinnt, hängt die Vertrauenswürdigkeit vollständig davon ab, dass der Host-Header nicht manipulierbar ist — d. h. der STS muss hinter einem Reverse-Proxy stehen, der den Host fixiert, und/oder ASP.NET AllowedHosts muss auf die erlaubten Hostnamen eingeschränkt sein (nicht *). Andernfalls könnte ein manipulierter Host-Header ein fremdes, selbstsigniertes Token als internes Cross-Tenant-Token erscheinen lassen. Fremde STS-Installationen werden unabhängig davon nur über die explizit konfigurierten InternalStsAdditionalHosts (Multi-Host) bzw. TrustedPartnerStsIssuers (Partner-STS) anerkannt.

Einschränkung (Multi-Host): Der Source-Tenant wird bei InternalSts-Quellen ausschliesslich über den Tenant-Namen erkannt (TokenExchange.AllowedSourceTenants, TrustedSourceTenants), nicht über den Host. Führen mehrere InternalStsAdditionalHosts denselben Tenant-Namen (z. B. ein Test- und ein Produktions-STS mit je einem Tenant stadt-x), sind diese für die Allow-Liste nicht unterscheidbar. Nur Hosts gleicher Vertrauensstufe zusammen als InternalStsAdditionalHosts konfigurieren; Installationen mit abweichender Vertrauensstufe stattdessen als TrustedPartnerStsIssuers (eigener Issuer-Key, eigene Audience) anbinden.

Sicherheitshinweis (JWKS-Transport): Die Signaturschlüssel (jwks_uri) eines Cross-Tenant- oder Multi-Host-Peers werden über dessen OIDC-Discovery bezogen. Dieser Abruf verlangt standardmässig HTTPS — ein Klartext-http://-Peer wird abgelehnt, da ein On-Path-Angreifer sonst fremde Signaturschlüssel unterschieben könnte. Nur für lokale Entwicklung bzw. On-Prem-Loopback kann http:// über den globalen Schlüssel InternalStsAllowInsecureHttp: true erlaubt werden (der STS gibt beim Start eine Warnung aus). InternalStsAdditionalHosts-Einträge in Produktion daher immer als https://… konfigurieren.

{
  "tenants": {

    // ─────────────────────────────────────────────────────────────────────────
    // Mandant T1 — Login + Quelle der subject_tokens für die TokenExchanges
    // ─────────────────────────────────────────────────────────────────────────
    "t1": {
      "DisplayName": "Mandant T1 (Login)",
      "CmiPrivateUri": "http://cmi-server-t1.example.com",
      "InternalTokenLifetimeSeconds": 3600,

      // Externer IdP (Beispiel: Azure AD). Wird sowohl für den interaktiven Login
      // (Angular) als auch für die Identity-Resolution im Cross-Tenant-Pfad gebraucht.
      "OpenIdConnectIdPs": {
        "azuread-t1": {
          "Authority": "https://login.microsoftonline.com/<AZURE_TENANT_ID>/v2.0",
          "ClientId": "<SPA_APP_ID>",
          "Audience": "<SPA_APP_ID>"
          // weitere übliche OIDC-Felder gemäss doc/IdentityProvider.md
        }
      },

      "Clients": [
        // ── 1) Angular-SPA: holt sich das T1-Token via Code-Flow + PKCE ─────────
        {
          "ClientId": "spa-angular-t1",
          "AllowedGrantTypes": [ "authorization_code" ],
          "RequirePkce": true,
          "RequireClientSecret": false,
          "AllowOfflineAccess": false,
          "RedirectUris":          [ "https://app.example.com/oidc-callback" ],
          "PostLogoutRedirectUris": [ "https://app.example.com/" ],
          "AllowedCorsOrigins":    [ "https://app.example.com" ],
          "AllowedScopes": [
            "openid", "profile",
            "t2-backend",  // wird vom API später beim TokenExchange angefragt
            "t3-backend"
          ]
        },

        // ── 2) Backend-API: Token-Exchange-Konsument + (optional) Actor-Issuer ──
        {
          "ClientId": "api-backend",
          "AllowedGrantTypes": [
            "urn:ietf:params:oauth:grant-type:token-exchange",
            "client_credentials"   // ← nur für Variante 2 (Delegation) nötig. Bei reiner
                                   //   Impersonation diese Zeile entfernen.
          ],
          "AllowedScopes": [ "t2-backend", "t3-backend" ],

          "ClientSecrets": [
            {
              "Type":  "SharedSecret",
              "Value": "<SHA512-HASH-DES-SECRETS>"
            }
          ],

          "TokenExchange": {
            // Ziel-Allow-Liste: das API darf Tokens für t2-backend und t3-backend anfragen.
            "AllowedAudiences": [ "t2-backend", "t3-backend" ],

            // Quell-Allow-Liste: subject_token muss aus dem eigenen Mandanten T1 kommen.
            "AllowedSourceTenants": [ "t1" ],

            // Delegation (nur Variante 2). Bei reiner Impersonation diese Zeilen entfernen.
            "AllowDelegation":     true,
            "AllowedActorIssuers": [ "https://sts-a.example.com/t1/identity" ],

            "ValidationClockSkewSeconds": 60
          }
        }
      ]
    },

    // ─────────────────────────────────────────────────────────────────────────
    // Mandant T2 — Cross-Tenant-Ziel, akzeptiert T1-Tokens
    // ─────────────────────────────────────────────────────────────────────────
    "t2": {
      "DisplayName": "Mandant T2 (Cross-Tenant-Ziel)",
      "CmiPrivateUri": "http://cmi-server-t2.example.com",
      "InternalTokenLifetimeSeconds": 3600,

      // Erlaube den eigenen Mandant T1 als Quelle für subject_tokens. ProviderKeyMapping
      // bildet den external_idp-Claim aus T1 auf den IdP-Schlüssel ab, der in T2s
      // OpenIdConnectIdPs eingetragen ist (siehe Cross-Tenant in TokenExchangeGrant.md).
      "TrustedSourceTenants": {
        "t1": {
          "ProviderKeyMapping": {
            "azuread-t1": "azuread-t2"
          }
        }
      },

      // T2s eigene IdP-Konfiguration für dieselbe Azure-AD-Föderation. Identity-Resolution
      // ruft den CMI-Server-T2 mit providerKey=azuread-t2 und external_sub auf.
      "OpenIdConnectIdPs": {
        "azuread-t2": {
          "Authority": "https://login.microsoftonline.com/<AZURE_TENANT_ID>/v2.0",
          "ClientId": "<APP_ID_T2>",
          "Audience": "<APP_ID_T2>"
        }
      },

      "Clients": [
        {
          "ClientId": "api-backend",
          "AllowedGrantTypes": [ "urn:ietf:params:oauth:grant-type:token-exchange" ],
          "AllowedScopes": [ "t2-backend" ],

          "ClientSecrets": [
            { "Type": "SharedSecret", "Value": "<SHA512-HASH-DES-SECRETS>" }
          ],

          "TokenExchange": {
            "AllowedAudiences":     [ "t2-backend" ],
            "AllowedSourceTenants": [ "t1" ],

            // Delegation-Optionen (nur Variante 2):
            "AllowDelegation":     true,
            "AllowedActorIssuers": [ "https://sts-a.example.com/t1/identity" ],

            "ValidationClockSkewSeconds": 60
          }
        }
      ]
    }
  }
}

Worauf zu achten ist:

  • Der Client api-backend existiert in beiden Tenants (T1 und T2). Tatsächlich angesprochen wird in Schritt 7 (/t2/identity/connect/token) der T2-Client — dort steht die Allow-Liste, die zur Ausstellung des T2-Tokens nötig ist. Der T1-Client ist nur für den client_credentials-Actor-Token-Aufruf (Variante 2) relevant.
  • TrustedSourceTenants.t1.ProviderKeyMapping ist Pflicht, wenn Quell- und Ziel-Mandant unterschiedliche IdP-Schlüssel für dieselbe föderierte Identität verwenden (typischer Fall). Ohne Mapping wird der Tausch mit STS935 abgelehnt (Default-Deny).
  • Bei reiner Impersonation entfallen pro Client die beiden Zeilen (TokenExchange.AllowDelegation, TokenExchange.AllowedActorIssuers) sowie der client_credentials-Grant im T1-Client.

5. Konfiguration STS-B (Mandant T3)

STS-B vertraut STS-A als Partner-STS. Das Subject-Token, das das API präsentiert, ist dasselbe T1-Token wie im Cross-Tenant-Pfad — STS-B validiert es allerdings über den OIDC-Discovery-Endpunkt von STS-A/T1 (https://sts-a.example.com/t1/identity/.well-known/openid-configuration) statt über den eigenen Tenant.

{
  "tenants": {
    "t3": {
      "DisplayName": "Mandant T3 (Partner-STS-Ziel)",
      "CmiPrivateUri": "http://cmi-server-t3.example.com",
      "InternalTokenLifetimeSeconds": 3600,

      // Vertraue STS-A/t1 als Subject-/Actor-Token-Quelle.
      // Schlüssel "sts-a-t1" ist der issuer-key, der im Client unter
      // TokenExchange.AllowedSourceIssuers referenziert wird.
      "TrustedPartnerStsIssuers": {
        "sts-a-t1": {
          "Authority": "https://sts-a.example.com/t1/identity",
          "Audience":  "https://sts-b.example.com/t3",
          "RequiredClaimRequirements": {
            // Optionale zusätzliche Strenge: lehnt STS-A-Tokens ab, die nicht aus T1 stammen.
            // Der "tid"-Claim ist STS-spezifisch — anpassen, falls der Tenant-Claim anders heisst.
            "tid": "t1"
          }
        }
      },

      "Clients": [
        {
          "ClientId": "api-backend",
          "AllowedGrantTypes": [ "urn:ietf:params:oauth:grant-type:token-exchange" ],
          "AllowedScopes": [ "t3-backend" ],

          "ClientSecrets": [
            { "Type": "SharedSecret", "Value": "<SHA512-HASH-DES-SECRETS>" }
          ],

          "TokenExchange": {
            "AllowedAudiences": [ "t3-backend" ],

            // AllowedSourceIssuers akzeptiert beides (siehe doc/TokenExchangeGrant.md):
            // - den internen Lookup-Key aus TrustedPartnerStsIssuers ("sts-a-t1") und
            // - den rohen iss-String, wie er im Subject-Token steht.
            "AllowedSourceIssuers": [ "sts-a-t1", "https://sts-a.example.com/t1/identity" ],

            // Delegation (nur Variante 2). Bei reiner Impersonation entfernen.
            // Actor-Token wird auf STS-B identisch validiert wie das subject_token,
            // d.h. via demselben Partner-STS-Trust.
            "AllowDelegation":     true,
            "AllowedActorIssuers": [ "sts-a-t1", "https://sts-a.example.com/t1/identity" ],

            "ValidationClockSkewSeconds": 60
          }
        }
      ]
    }
  }
}

Worauf zu achten ist:

  • Authority zeigt auf den Tenant-Issuer von STS-A/T1, nicht auf den STS-A-Hostnamen. CMI STS publiziert OIDC-Discovery pro Mandant unter /{tenant}/identity/.well-known/openid-configuration.
  • Audience ist der erwartete aud-Wert in den STS-A-Tokens, die für STS-B/T3 bestimmt sind. Sie muss als aud-Claim im T1-Token stehen — bei Partner-STS-Quellen wird der aud-Claim des Subject-Tokens immer gegen diesen Wert geprüft. STS-A muss also so konfiguriert werden, dass T1-Access-Tokens diese Audience führen — entweder über einen API-Scope oder über die aud-Steuerung von Duende (siehe doc/Scopes.md).
  • RequiredClaimRequirements ist optional, aber empfohlen — schliesst Tokens aus anderen STS-A-Mandanten strukturell aus.
  • TokenExchange.AllowedSourceIssuers akzeptiert sowohl den Schlüssel im TrustedPartnerStsIssuers-Dictionary (hier sts-a-t1) als auch den rohen iss-Wert aus dem Subject-Token. Ein Treffer auf einem der beiden genügt.
  • Auch hier: Bei reiner Impersonation entfallen die beiden Delegation-Zeilen.

6. API-Integration — Variante 1: Impersonation

Das Backend-API validiert das eingehende T1-Access-Token als reguläres JWT-Bearer-Token gegen den Issuer https://sts-a.example.com/t1/identity (Standard-Middleware des jeweiligen Frameworks, Discovery/JWKS-basiert). Anschliessend tauscht es dieses Token pro Ziel-Mandant über den jeweiligen Token-Endpunkt ein. Der einzige nicht alltägliche Baustein ist der Token-Exchange-POST selbst:

// Token-Exchange-Aufruf (Impersonation). httpClient.BaseAddress zeigt auf den
// Ziel-Tenant, z. B. https://sts-a.example.com/t2/identity/ für den Cross-Tenant-
// Tausch oder https://sts-b.example.com/t3/identity/ für den Partner-STS-Tausch.
var form = new Dictionary<string, string>
{
    ["grant_type"]         = "urn:ietf:params:oauth:grant-type:token-exchange",
    ["subject_token"]      = subjectToken, // T1-Token aus dem Authorization-Header, ohne "Bearer "
    ["subject_token_type"] = "urn:ietf:params:oauth:token-type:access_token",
    ["audience"]           = "t2-backend", // bzw. "t3-backend"
    ["scope"]              = "t2-backend",
    ["client_id"]          = "api-backend",
    ["client_secret"]      = clientSecret,
};

using var response = await httpClient.PostAsync("connect/token", new FormUrlEncodedContent(form), ct);

Die Antwort enthält das ausgestellte Access-Token im Feld access_token (Details siehe Grant Response). Empfehlungen für die Implementierung:

  • Die ausgestellten Tokens pro Ziel und Subject-Token cachen (z. B. IDistributedCache), mit einer Cache-Lebensdauer etwas unter expires_in. Als Cache-Schlüssel nie das Token selbst verwenden, sondern einen Hash.
  • Die Tokens dann als Authorization: Bearer ... an die Backends in T2 bzw. T3 senden.
  • subject_token und das ausgestellte Token nie im Klartext loggen.

Die vollständigen HTTP-Aufrufe als curl-Kommandos stehen in § 11.


7. API-Integration — Variante 2: Delegation

Variante 2 erweitert Variante 1 um die Beschaffung eines Actor-Tokens via client_credentials an STS-A/T1 und das Mitsenden als actor_token im Token-Exchange-POST:

  • Vor dem Token-Exchange holt das API ein eigenes Access-Token per grant_type=client_credentials (Client api-backend am Endpunkt https://sts-a.example.com/t1/identity/connect/token). Dieses Actor-Token sollte gecacht und bis kurz vor Ablauf wiederverwendet werden — es ist nicht benutzerbezogen.
  • Im Token-Exchange-POST aus § 6 kommen zwei Form-Felder hinzu: actor_token=<ACTOR_TOKEN> und actor_token_type=urn:ietf:params:oauth:token-type:access_token.

Die ausgestellten T2-/T3-Tokens enthalten dann einen act-Claim als JSON-Objekt:

{
  "iss": "https://sts-a.example.com/t2/identity",
  "sub": "user-cross-mapped-in-t2",
  "aud": "t2-backend",
  "act": {
    "sub": "api-backend",
    "iss": "https://sts-a.example.com/t1/identity"
  },
  "exp": 1747746000
}

Zur Verifikation kann der act-Claim aus dem JWT-Payload dekodiert werden (siehe § 11, Schritt 5). Die curl-Kommandos für den Delegations-Pfad stehen ebenfalls in § 11.


8. Pflicht-Claims im Subject-Token

Der Cross-Tenant-Pfad (T1 → T2 auf STS-A) erwartet im Subject-Token zwei Pflicht-Claims, die STS-A beim Login im Mandant T1 in das Token schreibt (siehe doc/Claims.md):

Claim Bedeutung
external_idp Provider-Key des externen IdPs im Quell-Mandanten (z. B. azuread-t1). Wird im Ziel-Mandanten über TrustedSourceTenants.<source>.ProviderKeyMapping auf den dortigen Provider-Key abgebildet.
external_sub Subject des Benutzers beim externen IdP (z. B. die Azure-AD-Object-ID des Benutzers). Wird unverändert an die CMI-Server-AuthenticateExternal-API im Ziel-Mandanten weitergegeben.

Fehlt einer der Claims, lehnt der TokenExchange-Grant mit STS939 ab. Die Claims werden nur für IssuerKind = InternalSts (Cross-Tenant) ausgewertet — beim Partner-STS-Pfad (STS-A → STS-B) übernimmt der ClaimsPrincipal als Ganzes.

Unabhängig davon verlangen alle Pfade einen nicht-leeren sub-Claim im Subject- und Actor-Token; Tokens ohne sub werden mit STS925 abgelehnt.

Der aud-Claim des Subject-Tokens wird im Cross-Tenant-Pfad nicht gegen einen bestimmten Wert geprüft — dort läuft der Audience-Check des Grants auf dem Request-audience-Parameter. Im Partner-STS-Pfad wird der aud-Claim dagegen immer gegen die konfigurierte TrustedPartnerStsIssuers.Audience geprüft (siehe § 5). Damit das Subject-Token überhaupt vom Browser an das API ausgehändigt wird, muss seine Audience zusätzlich zur API passen (aud=api-backend in der STS-A/T1-Client-Konfiguration).


9. may_act-Vorab-Autorisierung (optional)

RFC 8693 §4.4 erlaubt es, einen may_act-Claim im Subject-Token zu setzen, der vorab autorisiert, wer als Akteur auftreten darf. CMI STS macht aus dieser optionalen RFC-Empfehlung eine Pflicht-Policy:

  • Trägt das Subject-Token einen may_act-Claim und das API liefert kein actor_token mit, lehnt der TokenExchange-Grant mit STS941 ab.
  • Trägt das Subject-Token may_act = { "sub": "X", "iss": "Y" } und das mitgelieferte Actor-Token hat einen anderen sub oder iss, lehnt der Grant mit STS934 ab.

Konsequenz für diesen Walkthrough: Wenn STS-A im Login-Mandant T1 dem Subject-Token einen may_act-Claim mitgibt, ist Variante 1 (reine Impersonation) automatisch verboten und das API muss Variante 2 (Delegation) fahren. Standard-Login-Flows ohne explizite Konfiguration setzen may_act nicht — die Wahl Impersonation vs. Delegation bleibt dann frei.


10. Sicherheits-Checkliste

Die folgende Checkliste prüft die zentralen Angriffsvektoren gegen die Konfiguration und das API-Setup dieses Walkthroughs. Das vollständige Sicherheitsmodell steht in doc/TokenExchangeGrant.md:

  • Default-Deny: Jede Allow-Liste (TokenExchange.AllowedAudiences, TokenExchange.AllowedSourceTenants, TokenExchange.AllowedSourceIssuers, TokenExchange.AllowedActorIssuers, …) ist im Auslieferungszustand leer und sperrt damit den jeweiligen Vektor. Wildcards werden nicht unterstützt.
  • Audience-Smuggling: All-or-Nothing-Check gegen TokenExchange.AllowedAudiences. Wenn das API mehrere audience-Werte anfragt, müssen alle in der Allow-Liste stehen — Teilmenge führt zu STS930.
  • Delegation-Chain-Splicing: TokenExchange.AllowDelegation=false (Default) blockiert jedes actor_token mit STS933. Wer Delegation aktiviert, muss TokenExchange.AllowedActorIssuers füllen. Jeder sub eines erlaubten Actor-Issuers wird akzeptiert; ein bestimmter Akteur kann zusätzlich über den may_act-Claim im Subject-Token gepinnt werden (siehe § 9).
  • Algorithmus-Confusion: Die STS-Validierung der Subject-/Actor-Tokens akzeptiert nur RS256/PS256/ES256; none und symmetrische Verfahren werden abgelehnt.
  • Subject-Spoofing: Das API darf keine Identitäts-Claims im Form-Body mitsenden — sie würden vom STS ohnehin ignoriert. Der sub im ausgestellten Token kommt ausschliesslich aus der CMI-Server-Antwort.
  • Token-Lifetime-Extension: Das ausgestellte Token kann nie länger gültig sein als Subject- und Actor-Token (min(InternalTokenLifetimeSeconds, subject_rem, actor_rem)).
  • Log-Redaction: subject_token und actor_token werden im STS-Audit nie im Klartext geloggt. Das API sollte beim eigenen Logging dasselbe tun — Authorization-Header und Form-Body niemals strukturiert mit Token-Inhalt persistieren.
  • TLS: Alle Endpoints — STS-A, STS-B, API, Backends — müssen TLS 1.2+ erzwingen.

11. Verifikation mit curl

Reihenfolge zum Verifizieren eines neuen Setups, von „läuft der STS überhaupt" bis „Delegation produziert act-Claim". Alle Befehle setzen voraus, dass die Konfiguration aus §4 und §5 geladen ist und beide STS-Installationen auf den oben genannten Hostnamen erreichbar sind.

# 0) Discovery — STS-A/T1, STS-A/T2, STS-B/T3 müssen alle ein valides OIDC-Doc liefern.
curl -s https://sts-a.example.com/t1/identity/.well-known/openid-configuration | jq .grant_types_supported
curl -s https://sts-a.example.com/t2/identity/.well-known/openid-configuration | jq .grant_types_supported
curl -s https://sts-b.example.com/t3/identity/.well-known/openid-configuration | jq .grant_types_supported
# Erwartet: enthält "urn:ietf:params:oauth:grant-type:token-exchange"

# 1) Login (manuell via Browser auf STS-A/T1 oder via SPA-Auth-Library).
#    Resultat: T1-Access-Token, das gleich als $SUBJECT_TOKEN gesetzt wird.
SUBJECT_TOKEN="eyJhbGciOiJSUzI1NiI..."

# 2) Variante 1 — Impersonation: T2-Token via Cross-Tenant TokenExchange auf STS-A/T2.
curl -s -X POST https://sts-a.example.com/t2/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SUBJECT_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "audience=t2-backend" \
  -d "scope=t2-backend" \
  -d "client_id=api-backend" \
  -d "client_secret=<SECRET>" \
  | jq .
# Erwartet: { "access_token": "...", "issued_token_type": "...:access_token", "token_type": "Bearer", "expires_in": ... }

# 3) Variante 1 — Impersonation: T3-Token via Partner-STS auf STS-B/T3 (identische Form, anderer Endpoint).
curl -s -X POST https://sts-b.example.com/t3/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SUBJECT_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "audience=t3-backend" \
  -d "scope=t3-backend" \
  -d "client_id=api-backend" \
  -d "client_secret=<SECRET>" \
  | jq .

# 4) Variante 2 — Delegation: Actor-Token holen, dann TokenExchange mit actor_token.
ACTOR_TOKEN=$(curl -s -X POST https://sts-a.example.com/t1/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=client_credentials" \
  -d "scope=t2-backend t3-backend" \
  -d "client_id=api-backend" \
  -d "client_secret=<SECRET>" | jq -r .access_token)

# 5) T2-Token mit subject + actor + Verifikation des act-Claims im ausgestellten JWT.
T2_TOKEN=$(curl -s -X POST https://sts-a.example.com/t2/identity/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
  -d "subject_token=$SUBJECT_TOKEN" \
  -d "subject_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "actor_token=$ACTOR_TOKEN" \
  -d "actor_token_type=urn:ietf:params:oauth:token-type:access_token" \
  -d "audience=t2-backend" \
  -d "client_id=api-backend" \
  -d "client_secret=<SECRET>" | jq -r .access_token)

# JWT-Payload dekodieren und act-Claim prüfen
echo "$T2_TOKEN" | awk -F. '{print $2}' | base64 -d 2>/dev/null | jq .act
# Erwartet: { "sub": "api-backend", "iss": "https://sts-a.example.com/t1/identity" }

# 6) T3-Token mit subject + actor: wie Schritt 5, aber gegen https://sts-b.example.com/t3/...
#    mit audience=t3-backend.

Schlägt einer der Schritte fehl, geht es in §12.


12. Häufige Fehler und Fixes

Alle Fehlercodes sind zentral in doc/ErrorCodes.md dokumentiert. Die wahrscheinlichsten in dieser Topologie:

Code Symptom Fix
STS925 Subject-/Actor-Token verletzt das JWT-Profil (Signatur, iss, sub, exp, iat; bei Partner-STS auch aud). Bei Partner-STS: Authority exakt = iss im Token, Audience = aud-Claim; JWKS-Endpoint von STS-A muss von STS-B erreichbar sein. Prüfen, ob das Token einen nicht-leeren sub-Claim trägt.
STS929 Subject-Token-Quelle nicht erlaubt. T2-Client: TokenExchange.AllowedSourceTenants muss t1 enthalten. T3-Client: TokenExchange.AllowedSourceIssuers muss sts-a-t1 oder den rohen iss-Wert enthalten.
STS930 invalid_target, Audience fehlt oder unbekannt. audience=t2-backend bzw. =t3-backend mitsenden und in TokenExchange.AllowedAudiences des jeweiligen Clients eintragen.
STS933 unauthorized_client bei mitgesendetem actor_token. TokenExchange.AllowDelegation=true setzen oder das Actor-Token weglassen (Variante 1).
STS934 may_act im Subject-Token passt nicht zum Actor. Actor anpassen (passende client_credentials-Identity) oder may_act im Subject-Token-Setup korrigieren.
STS935 external_idp ohne Mapping (Default-Deny). Eintrag in TrustedSourceTenants.<T1>.ProviderKeyMapping ergänzen — bei identischem Provider-Key-Namensraum als identisches Paar.
STS939 Cross-Tenant-Claims fehlen im Subject-Token. T1-IdP-Konfiguration anpassen, sodass external_idp/external_sub in das Token geschrieben werden (siehe doc/Claims.md).
STS940 Actor-Issuer nicht erlaubt. TokenExchange.AllowedActorIssuers prüfen — der Issuer muss als roher iss-String oder als Issuer-Key eingetragen sein. Der sub des Actors wird nicht per Allow-Liste eingeschränkt.
STS941 may_act vorhanden, aber kein actor_token mitgeliefert. Variante 2 verwenden (Actor-Token beschaffen und mitsenden).
STS946 Pflicht-Claim aus RequiredClaimRequirements verletzt. Claim-Wert im Subject-Token mit dem konfigurierten Pflicht-Wert vergleichen — Schreibweise und Encoding strikt prüfen.

13. Verweise