Skip to content

Add Azure spiffe oidc auth profile #3797

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

Conversation

jjcollinge
Copy link
Contributor

@jjcollinge jjcollinge commented Apr 16, 2025

Signed-off-by: Jonathan Collinge [email protected]

Description

Adds a new Azure SPIFFE OIDC authentication profile. This allows Azure users to create federated credentials for Azure AD (EntraID) Applications that directly target the SPIFFE ID of the Dapr App. This allows users to natively integrate their Dapr SPIFFE IDs into their IAM and achieve cross cloud federation without needing to host the application on the same provider and leverage the native IAM providers. For instance Dapr deployment running on AWS could access resources on Azure without needing to use secrets via a federated app credential.

This PR builds on

Steps to reproduce

# Check out the required PRs

# Update go mods to use local builds

# Build local binaries from dapr/dapr for your arch/os
export DAPR_REGISTRY=docker.io/<your_docker_hub_account>
export DAPR_TAG=dev
TARGET_ARCH=arm64 GOOS=linux GOARCH=arm64 make build-linux
TARGET_ARCH=arm64 GOOS=linux GOARCH=arm64 make docker-build
TARGET_ARCH=arm64 GOOS=linux GOARCH=arm64 make docker-push

# Create KinD cluster
kind create cluster --name dapr

# Create Azure resources
az group create $GROUP
az storage account create --name $ACCOUNT_NAME --resource-group $GROUP --location $LOCATION --sku Standard_LRS
az storage container create --account-name $ACCOUNT_NAME -n $CONTAINER_NAME

# Create TLS certs for your public domain

# Use the helper below to generate a jwt.key and jwks.json and then create the dapr trust bundle in dapr-system namespace

# Deploy Dapr
TARGET_ARCH=arm64 GOOS=linux GOARCH=arm64 ADDITIONAL_HELM_SET=dapr_sentry.jwt.enabled=true,dapr_sentry.jwt.audiences=["api://AzureADTokenExchange"],dapr_sentry.jwt.issuer=https://$DOMAIN,dapr_sentry.oidc.httpPort=9082,dapr_sentry.oidc.tlsCertFile=/var/run/secrets/dapr.io/oidc/tls.crt,dapr_sentry.oidc.tlsKeyFile=/var/run/secrets/dapr.io/oidc/tls.key,global.extraVolumes.sentry[0].name=oidc,global.extraVolumes.sentry[0].secret.secretName=$TLS_SECRET,global.extraVolumeMounts.sentry[0].name=oidc,global.extraVolumeMounts.sentry[0].mountPath=/var/run/secrets/dapr.io/oidc,global.extraVolumeMounts.sentry[0].readOnly=true
 make docker-deploy-k8s

# Expose OIDC server to the internet via server, ingress or tunnel

# Configure Azure AD (EntraID)

cat > creds.json <<EOF
{ 
  "name": "DaprSpiffe", 
  "issuer": "https://$DOMAIN", 
  "subject": spiffe://public/ns/default/app1",
  "audiences": ["api://AzureADTokenExchange"], 
  "description": "App1 Dapr App Access" 
}
EOF

az ad app create --display-name spiffeapp --enable-access-token-issuance --enable-id-token-issuance
az ad sp create --id $APP_ID
az role assignment create --assignee-object-id $APP_ID --assignee-principal-type ServicePrincipal --role "Storage Blob Data Owner" --scope "/subscriptions/$SUBSCRIPTION/resourceGroups/$GROUP/providers/Microsoft.Storage/storageAccounts/$ACCOUNT_NAME"
az ad app federated-credential create --id $APP_ID --parameters creds.json
CLIENT_ID=$(az ad app show --id $APP_ID --query appId --output tsv)
TENANT_ID=$(az account show --query tenantId --output tsv)

# Creata a Dapr component

cat > azureblob.yaml <<EOF
apiVersion: dapr.io/v1alpha1
kind: Component
metadata:
  name: azureblob
spec:
  type: state.azure.blobstorage
  version: v2
  metadata:
  - name: accountName
    value: $ACCOUNT_NAME
  - name: containerName
    value: $CONTAINER_NAME
  - name: clientId
    value: $CLIENT_ID
  - name: tenantId
    value:  $TENANT_ID
EOF


# Deploy a Dapr app with app id app1 in the default namespace that uses the azure blob component

Helper to create jwt.key and jwks.json

package main

import (
	"crypto"
	"crypto/ecdsa"
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/base64"
	"encoding/json"
	"encoding/pem"
	"errors"
	"fmt"
	"os"
	"reflect"

	"github.com/lestrrat-go/jwx/v2/jwk"
)

func main() {
	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	//privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
	if err != nil {
		panic(fmt.Sprintf("Failed to generate key: %v", err))
	}

	// Create jwt.key (PEM format)
	keyPKCS8, err := x509.MarshalPKCS8PrivateKey(privateKey)
	if err != nil {
		panic(fmt.Sprintf("Failed to marshal private key: %v", err))
	}

	keyPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "PRIVATE KEY",
		Bytes: keyPKCS8,
	})

	// Create JWKS with the same key
	key, err := jwk.FromRaw(privateKey.Public())
	if err != nil {
		panic(fmt.Sprintf("Failed to create JWK: %v", err))
	}

	// Set necessary attributes
	if err := key.Set(jwk.KeyIDKey, "dapr-sentry-jwt"); err != nil {
		panic(fmt.Sprintf("Failed to set key ID: %v", err))
	}
	if err := key.Set(jwk.AlgorithmKey, "ES256"); err != nil {
		panic(fmt.Sprintf("Failed to set algorithm: %v", err))
	}
	if err := key.Set(jwk.KeyUsageKey, "sig"); err != nil {
		panic(fmt.Sprintf("Failed to set key usage: %v", err))
	}

	// Create a key set with this key
	keySet := jwk.NewSet()
	keySet.AddKey(key)

	// Marshal the JWKS to JSON
	jwksBytes, err := json.MarshalIndent(keySet, "", "  ")
	if err != nil {
		panic(fmt.Sprintf("Failed to marshal JWKS: %v", err))
	}

	// Validate the key pair
	if err := verifyJWKS(jwksBytes, privateKey); err != nil {
		panic(fmt.Sprintf("Key validation failed: %v", err))
	} else {
		fmt.Println("✅ Keys validated successfully! They match and should work with Dapr.")
	}

	// Write files
	if err := os.WriteFile("jwt.key", keyPEM, 0600); err != nil {
		panic(fmt.Sprintf("Failed to write jwt.key: %v", err))
	}

	if err := os.WriteFile("jwks.json", jwksBytes, 0644); err != nil {
		panic(fmt.Sprintf("Failed to write jwks.json: %v", err))
	}

	// Print base64 versions for Kubernetes
	fmt.Println("\n📋 Base64 encoded jwt.key (for Kubernetes):")
	fmt.Println(base64.StdEncoding.EncodeToString(keyPEM))
	fmt.Println("\n📋 Base64 encoded jwks.json (for Kubernetes):")
	fmt.Println(base64.StdEncoding.EncodeToString(jwksBytes))

	fmt.Println("\n✅ Files created successfully:")
	fmt.Println("   - jwt.key")
	fmt.Println("   - jwks.json")

	fmt.Println("\n🔄 Update your Kubernetes secret with:")
	fmt.Println("kubectl create secret generic dapr-trust-bundle \\\n" +
		"  --namespace dapr-system \\\n" +
		"  --from-file=jwt.key=jwt.key \\\n" +
		"  --from-file=jwks.json=jwks.json \\\n" +
		"  --dry-run=client -o yaml | kubectl apply -f -")
}

// verifyJWKS verifies that the JWKS is valid and contains a corresponding
// public key for the provided signing key.
func verifyJWKS(jwksBytes []byte, signingKey crypto.Signer) error {
	if signingKey == nil {
		// If no signing key is provided but JWKS exists, we can't verify the match
		return errors.New("can't verify JWKS without signing key")
	}

	// Parse the JWKS
	keySet, err := jwk.Parse(jwksBytes)
	if err != nil {
		return fmt.Errorf("failed to parse JWKS: %w", err)
	}

	// Make sure the JWKS has at least one key
	if keySet.Len() == 0 {
		return errors.New("JWKS doesn't contain any keys")
	}

	// Convert signing key to JWK
	privateJWK, err := jwk.FromRaw(signingKey)
	if err != nil {
		return fmt.Errorf("failed to convert signing key to JWK: %w", err)
	}

	// Get the public key part
	publicJWK, err := privateJWK.PublicKey()
	if err != nil {
		return fmt.Errorf("failed to extract public key from JWT signing key: %w", err)
	}

	// Get the key ID if it exists on the signing key
	var signingKeyID string
	if kid, ok := publicJWK.Get(jwk.KeyIDKey); ok {
		if s, ok := kid.(string); ok {
			signingKeyID = s
		}
	}

	// Verify that the public key is in the JWKS
	found := false
	matchAttempted := false

	for i := 0; i < keySet.Len(); i++ {
		key, _ := keySet.Key(i)

		// If both keys have key IDs, check if they match first (faster path)
		if signingKeyID != "" {
			if kid, ok := key.Get(jwk.KeyIDKey); ok {
				if s, ok := kid.(string); ok && s == signingKeyID {
					found = true
					break
				}
			}
		}

		// If key ID check wasn't successful, compare the key contents
		matchAttempted = true

		// First, ensure we're comparing public keys
		keyPublic, err := key.PublicKey()
		if err != nil {
			continue
		}

		// Check if the key has a valid "use" field (if present)
		if use, ok := keyPublic.Get(jwk.KeyUsageKey); ok {
			if s, ok := use.(string); ok && s != "sig" {
				// Skip keys not meant for signature verification
				continue
			}
		}

		// Get raw representations of both keys to compare
		var pubRaw interface{}
		if err = keyPublic.Raw(&pubRaw); err != nil {
			continue
		}

		var signerPubRaw interface{}
		if err = publicJWK.Raw(&signerPubRaw); err != nil {
			continue
		}

		// Type-specific comparisons for different key types
		switch pubKey := pubRaw.(type) {
		case *ecdsa.PublicKey:
			if signerPubKey, ok := signerPubRaw.(*ecdsa.PublicKey); ok {
				// For ECDSA, compare the curve, X and Y values
				if pubKey.Curve == signerPubKey.Curve &&
					pubKey.X.Cmp(signerPubKey.X) == 0 &&
					pubKey.Y.Cmp(signerPubKey.Y) == 0 {
					found = true
				}
			}
		default:
			// For other key types, try direct comparison
			if reflect.TypeOf(pubRaw) == reflect.TypeOf(signerPubRaw) {
				// If keys are the same type and same value, they are equal
				if reflect.DeepEqual(pubRaw, signerPubRaw) {
					found = true
				}
			}
		}

		if found {
			break
		}
	}

	if !found {
		if !matchAttempted {
			return fmt.Errorf("JWKS doesn't contain a key with matching key ID '%s'", signingKeyID)
		}
		return errors.New("JWKS doesn't contain a matching public key for the JWT signing key")
	}

	return nil
}

Issue reference

We strive to have all PR being opened based on an issue, where the problem or feature have been discussed prior to implementation.

Please reference the issue this PR will close: #[issue number]

Checklist

Please make sure you've completed the relevant tasks for this PR, out of the following list:

@jjcollinge jjcollinge requested review from a team as code owners April 16, 2025 07:03
Copy link
Contributor

@JoshVanL JoshVanL left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good, but there are a number of unrelated changes for some merge I think- Looks like we need a hard merge, and likely also useful to merge this first.

@jjcollinge jjcollinge force-pushed the jjcollinge/azure-oidc branch from 0b429cd to 725a984 Compare May 23, 2025 06:13
Signed-off-by: Jonathan Collinge <[email protected]>
Signed-off-by: Jonathan Collinge <[email protected]>
Signed-off-by: Jonathan Collinge <[email protected]>
Signed-off-by: Jonathan Collinge <[email protected]>
Signed-off-by: Jonathan Collinge <[email protected]>
Signed-off-by: Jonathan Collinge <[email protected]>
@jjcollinge jjcollinge force-pushed the jjcollinge/azure-oidc branch from 50d617a to 6cdd440 Compare June 10, 2025 10:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants