Secret Management und vor allem der Austausch solcher sicherheitsrelevanten Artefakten ist ein schwieriges Thema welches meiner Erfahrung nach auch häufig ziemlich Stiefmütterlich behandelt wird. Gerade in Version Control Systems (VCS) wie zum Beispiel Git findet man viel zu oft ein Secret, welches in einer Applikation für die Single Sign On Anbindung via OpenID Connect (OIDC) verwendet wird. Auch der Austausch von Secrets mit Partner oder Lieferanten erfolgt gerne mal über unsichere Kanäle wie Email, bei dem die Verschlüsselung nicht durchgängig sichergestellt ist.

Microsoft Entra ID bietet nebst dem klassischen Secret und einem Zertifikat, welches als asymmetrischer Schlüssel verwendet wird, die Möglichkeit dem OIDC Token eines fremden Identity Providers (IdP) zu vertrauen. Letzteres ist sehr interessant, da es einerseits die Gefahr von leaked Secrets mitigiert und andererseits der Fall nicht eintreffen kann, dass ein Secret oder Zertifikat abläuft und dadurch der Service gestört ist. Zudem ist es, einmal eingerichtet, frei von jeglichem Wartungsaufwand.

Verwendungszweck

Federated Credentials können für Software Workloads eingesetzt werden, wo üblicherweise ein Secret oder Zertifikat genutzt wird. Ein hervorragendes Beispiel hierfür ist Terraform (Enterprise und Cloud). Wenn ein Terraform Workspace den Provider azuread oder azurerm verwendet kann mit Federated Credentials ein kurzlebiges OIDC Token von Terraform selber ausgestellt werden, welches bei Entra ID gegen ein Token des jeweiligen Service Principals getauscht wird. Mit letzterem Token und dessen entsprechenden Microsoft Graph Berechtigungen wird dann der Terraform Plan ausgeführt.

Voraussetzungen

  • OpenID Provider Configuration Document muss öffentlich verfügbar sein
  • JSON Web Key Set (JWKS) muss öffentlich verfügbar sein
  • Issuer muss eine öffentlich erreichbare URL sein

Wie funktioniert es

Zu Demonstrationszwecken erstelle ich auf einer Keycloak Instanz einen OIDC Client mit Service account roles, welcher den Client Credentials Flow ermöglicht. Wichtig ist dabei die Audience auf api://AzureADTokenExchange zu setzen (oder alternativ in Entra ID die Audience ändern)

keycloak-audience

Auf dem Service Principal in Microsoft Entra ID wähle ich als Szenario “Other issuer” und trage ich den Issuer und den Subject Identifier des OIDC Clients von Keycloak ein um die Vertrauenstellung zu erstellen.

entraid-federated-credentials

Mit Hilfe von Postman hole ich mir mit dem Client Credentials Flow ein ID Token von Keycloak unter Angabe der foldenden Parametern:

  • client_id: fc-demo-blog
  • client_secret: -in Keycloak generiert-
  • scope: openid
  • grant_type: client_credentials

postman-keycloak

Im Payload des resultierenden ID Token sind die gewünschten Claims iss (für Issuer), sub (für Subject) und aud (für Audience), welche den Einstellungen in den Federated Credentials in Microsoft Entra ID entsprechen müssen.

kc-token-payload

Dieses ID Token wiederum verwende ich nun erneut in Postman für den Token Austausch mit Microsoft Entra ID unter Angabe der folgenden Parametern:

  • scope: https://graph.microsoft.com/.default (damit ich alle App Roles erhalte, die dem Service Prinicpal zugewiesen sind)
  • client_id: entspricht der Client ID des Service Principals, auf dem die Federated Credentials hinterlegt sind
  • client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
  • grant_type: client_credentials
  • client_assertion: das ID Token, welches ich von Keycloak erhalten habe

postman-entraid

Microsoft Entra ID stellt mir nun ein Access Token aus, welches ich verwenden kann, um auf die entsprechenden APIs zuzugreifen.

entraid-token-payload

Token Forging

Da ich genauer wissen wollte, wie der Federated Credentials Token überprüft wird und keine Dokumentation darüber gefunden habe (ausser, dass der Issuer und das Subject geprüft wird) startet ich selber ein kleines Experiment.

In Go habe ich mir ein kleines Tool geschrieben, dass mir ein JSON Web Token (JWT) mit exakt denselben Claims wie das Keycloak Token ausstellt. Mit einem symmetrischen Schlüssel (HS256) für die Signatur erhalte ich von Microsoft Entra ID folgende Antwort:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "error": "invalid_client",
    "error_description": "AADSTS5002738: Invalid JWT token. 'HS256' is not a supported signature algorithm. Supported signing algorithms are: 'RS256,RS384,RS512,PS256,PS384,PS512' Trace ID: b6fd0e76-9110-4688-a9ec-7779dd364100 Correlation ID: 05911e5c-df22-455e-8795-29f68e052750 Timestamp: 2024-09-01 17:38:30Z",
    "error_codes": [
        5002738
    ],
    "timestamp": "2024-09-01 17:38:30Z",
    "trace_id": "b6fd0e76-9110-4688-a9ec-7779dd364100",
    "correlation_id": "05911e5c-df22-455e-8795-29f68e052750",
    "error_uri": "https://login.microsoftonline.com/error?code=5002738"
}

Ok damit wäre mal klar, dass ein asymmetrischer Schlüssel verwendet werden muss (was sich mit meinen Erwartungen an dieses Feature deckt). Weiter gehts mit einem asymmetrischen Schlüssel (RS256) und einem fiktiven kid (Key ID) im Header. Dies liefert mir das Resultat:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "error": "invalid_client",
    "error_description": "AADSTS7000272: The certificate with identifier 1 used to sign the federated credential could not be found in the metadata of identity provider https://sts.irbe.ch/realms/irbe. Please validate that your Identity Provider's metadata exposes the key used to sign the incoming token. Trace ID: 553b5bce-768b-4d81-8c93-5e981e3f1e00 Correlation ID: 5f9856a8-b344-4d4d-86c4-3dd0171508b6 Timestamp: 2024-09-01 17:42:28Z",
    "error_codes": [
        7000272
    ],
    "timestamp": "2024-09-01 17:42:28Z",
    "trace_id": "553b5bce-768b-4d81-8c93-5e981e3f1e00",
    "correlation_id": "5f9856a8-b344-4d4d-86c4-3dd0171508b6",
    "error_uri": "https://login.microsoftonline.com/error?code=7000272"
}

Die Antwort bedeutet, dass die OIDC Metadaten URL aus derjenigen des Issuer Claims abgeleitet wird. Nun bleibt noch den korrekten kid (Key ID) im Header anzugeben und zu bestätigen, dass die Signature anhand des Public Keys geprüft wird.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
    "error": "invalid_client",
    "error_description": "AADSTS7000274: Key  was found, but use of the key to verify the signature failed. Trace ID: 04cc194a-75cf-46fe-938a-8d2cdd3c2300 Correlation ID: 0902bcf3-6840-4bbd-a663-4097e9b17cbf Timestamp: 2024-09-01 17:47:48Z",
    "error_codes": [
        7000274
    ],
    "timestamp": "2024-09-01 17:47:48Z",
    "trace_id": "04cc194a-75cf-46fe-938a-8d2cdd3c2300",
    "correlation_id": "0902bcf3-6840-4bbd-a663-4097e9b17cbf",
    "error_uri": "https://login.microsoftonline.com/error?code=7000274"
}

Und genau so ist es! Das heisst Microsoft Entra ID prüft die OIDC Metadaten, welche vom Issuer Claim abgeleitet werden und verifiziert die Signatur. Damit wird es einem Angreifer stark erschwert ein Token selber zu erstellen um den Service Principal zu missbrauchen, er müsste dafür den fremden Identity Provider übernehmen. Der Quellcode für die Tests ist folgender:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
	"crypto/rand"
	"crypto/rsa"
	"fmt"
	"time"

	"github.com/golang-jwt/jwt/v4"
)

func HS256(c jwt.MapClaims) string {
	token := jwt.NewWithClaims(jwt.SigningMethodHS256, c)
	s, _ := token.SignedString([]byte("secret"))
	return s
}

func RS256InvalidKey(c jwt.MapClaims) string {
	key, _ := rsa.GenerateKey(rand.Reader, 2048)
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
	token.Header["kid"] = "1"
	s, _ := token.SignedString(key)
	return s
}

func RS256CorrectKey(c jwt.MapClaims) string {
	key, _ := rsa.GenerateKey(rand.Reader, 2048)
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, c)
	token.Header["kid"] = "iJMFj7jRxNvFsYRa9KTwFdOLibQPvbyCdt3wypGwNmY"
	s, _ := token.SignedString(key)
	return s
}

func main() {
	claims := jwt.MapClaims{
		"auth_time":          0,
		"iat":                time.Now().Unix(),
		"nbf":                time.Now().Unix(),
		"exp":                time.Now().Add(time.Hour).Unix(),
		"jti":                "cf7ee0a9-064a-4c4a-a34c-969642bae595",
		"iss":                "https://sts.irbe.ch/realms/irbe",
		"aud":                "api://AzureADTokenExchange",
		"sub":                "792700fc-d0a5-4266-b0d4-96ad9bda4cbf",
		"typ":                "ID",
		"azp":                "fc-demo-blog",
		"at_hash":            "vb-hz9536emiW_qG455hJg",
		"acr":                "1",
		"clientHost":         "54.86.50.139",
		"email_verified":     false,
		"preferred_username": "service-account-fc-demo-blog",
		"clientAddress":      "54.86.50.139",
		"client_id":          "fc-demo-blog",
	}

	fmt.Println(HS256(claims))
	fmt.Println(RS256InvalidKey(claims))
	fmt.Println(RS256CorrectKey(claims))
}

Verwendung in Terraform

Ein sehr gutes Beispiel, wo Federated Credentials eingesetzt werden können, ist für mich Terraform weil damit die ganzen Wartungsaufwände für Secrets wegfallen. Dafür wird in den Terraform Workspace Variablen folgende zwei Einträge gemacht:

  • TFC_AZURE_PROVIDER_AUTH = true
  • TFC_AZURE_RUN_CLIENT_ID = Client ID des Service Principals in Entra ID

In der Provider Konfiguration muss der Tenant angegeben werden und use_oidc = true gesetzt werden

1
2
3
4
5
provider "azuread" {
  use_cli   = false
  use_oidc  = true
  tenant_id = var.tenant_id
}

Und schlussendlich noch die Federated Credentials Konfiguration auf der App Registration in Microsoft Entra ID. Hier müssen zwei Einträge angelegt werden, eines für die Plan Phase und eines für die Apply Phase. Der Subject Identitfier setzt sich aus den Elementen Organisation, Project, Workspace und Run Phase zusammen und kann somit sehr granular auf die Service Principals (zur Erinnerung: die Kombination aus Issuer und Subject muss eindeutig sein im Tenant) verteilt werden.

  • Issuer: https://app.terraform.io
  • Subject Identifier: organization:irbech:project:Default Project:workspace:entraid-apps:run_phase:plan

und

  • Issuer: https://app.terraform.io
  • Subject Identifier: organization:irbech:project:Default Project:workspace:entraid-apps:run_phase:apply