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 (ohneactor_token) und Delegation (mitactor_tokenundact-Claim) — werden parallel dokumentiert. Die Client-Authentifizierung am STS erfolgt in diesem Walkthrough durchgängig per Shared-Secret.
Inhaltsverzeichnis
- 1. Topologie und Szenario
- 2. Impersonation vs. Delegation
- 3. End-to-End-Flow
- 4. Konfiguration STS-A (Mandanten T1 und T2)
- 5. Konfiguration STS-B (Mandant T3)
- 6. API-Integration — Variante 1: Impersonation
- 7. API-Integration — Variante 2: Delegation
- 8. Pflicht-Claims im Subject-Token
- 9.
may_act-Vorab-Autorisierung (optional) - 10. Sicherheits-Checkliste
- 11. Verifikation mit curl
- 12. Häufige Fehler und Fixes
- 13. Verweise
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 unteract.acterhalten, 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 dessenisszur Form{Origin}/{tenant}/identitypasst.Originwird 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üsselInternalStsExpectedOrigin(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 derHost-Header nicht manipulierbar ist — d. h. der STS muss hinter einem Reverse-Proxy stehen, der denHostfixiert, und/oder ASP.NETAllowedHostsmuss auf die erlaubten Hostnamen eingeschränkt sein (nicht*). Andernfalls könnte ein manipulierterHost-Header ein fremdes, selbstsigniertes Token als internes Cross-Tenant-Token erscheinen lassen. Fremde STS-Installationen werden unabhängig davon nur über die explizit konfiguriertenInternalStsAdditionalHosts(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 mehrereInternalStsAdditionalHostsdenselben Tenant-Namen (z. B. ein Test- und ein Produktions-STS mit je einem Tenantstadt-x), sind diese für die Allow-Liste nicht unterscheidbar. Nur Hosts gleicher Vertrauensstufe zusammen alsInternalStsAdditionalHostskonfigurieren; Installationen mit abweichender Vertrauensstufe stattdessen alsTrustedPartnerStsIssuers(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 kannhttp://über den globalen SchlüsselInternalStsAllowInsecureHttp: trueerlaubt werden (der STS gibt beim Start eine Warnung aus).InternalStsAdditionalHosts-Einträge in Produktion daher immer alshttps://…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-backendexistiert 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 denclient_credentials-Actor-Token-Aufruf (Variante 2) relevant. TrustedSourceTenants.t1.ProviderKeyMappingist 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 mitSTS935abgelehnt (Default-Deny).- Bei reiner Impersonation entfallen pro Client die beiden Zeilen (
TokenExchange.AllowDelegation,TokenExchange.AllowedActorIssuers) sowie derclient_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:
Authorityzeigt 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.Audienceist der erwarteteaud-Wert in den STS-A-Tokens, die für STS-B/T3 bestimmt sind. Sie muss alsaud-Claim im T1-Token stehen — bei Partner-STS-Quellen wird deraud-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 dieaud-Steuerung von Duende (siehedoc/Scopes.md).RequiredClaimRequirementsist optional, aber empfohlen — schliesst Tokens aus anderen STS-A-Mandanten strukturell aus.TokenExchange.AllowedSourceIssuersakzeptiert sowohl den Schlüssel imTrustedPartnerStsIssuers-Dictionary (hiersts-a-t1) als auch den roheniss-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 unterexpires_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_tokenund 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(Clientapi-backendam Endpunkthttps://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>undactor_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 keinactor_tokenmit, lehnt der TokenExchange-Grant mitSTS941ab. - Trägt das Subject-Token
may_act = { "sub": "X", "iss": "Y" }und das mitgelieferte Actor-Token hat einen anderensuboderiss, lehnt der Grant mitSTS934ab.
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 mehrereaudience-Werte anfragt, müssen alle in der Allow-Liste stehen — Teilmenge führt zuSTS930. - Delegation-Chain-Splicing:
TokenExchange.AllowDelegation=false(Default) blockiert jedesactor_tokenmitSTS933. Wer Delegation aktiviert, mussTokenExchange.AllowedActorIssuersfüllen. Jedersubeines erlaubten Actor-Issuers wird akzeptiert; ein bestimmter Akteur kann zusätzlich über denmay_act-Claim im Subject-Token gepinnt werden (siehe § 9). - Algorithmus-Confusion: Die STS-Validierung der Subject-/Actor-Tokens akzeptiert nur RS256/PS256/ES256;
noneund 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
subim 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_tokenundactor_tokenwerden 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
doc/TokenExchangeGrant.md— Operator-Referenz für den Grant (Konfiguration, Sicherheitsmodell, Metriken).doc/ErrorCodes.md— vollständige Fehlercode-Referenz (Token Exchange: STS92x–STS951).doc/OnBehalfOfGrant.md— Vergleichsreferenz, der ältere OBO-Grant bleibt parallel verfügbar.doc/DelegationGrant.md— Hintergrund zum CMI-proprietären Delegation-Grant (nicht identisch mit RFC-8693-Delegation).doc/Claims.md,doc/IdentityProvider.md— Subject-/IDP-Claims, IDP-Konfiguration.- RFC 8693 — OAuth 2.0 Token Exchange.