Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 36 additions & 1 deletion docs/ENTRA_ID_SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ From the app's **Overview** page, copy:

### Configure Client Credentials

You need **one** of the following — a client secret or a certificate. If you only need MCP server authentication (no other systems sharing this app registration), certificate-based auth is recommended.
You need **one** of the following — a client secret, a certificate, or a federated identity credential. If you only need MCP server authentication (no other systems sharing this app registration), certificate-based auth is recommended. If your workload runs in an environment with an external identity provider (e.g., SPIRE), use federated credentials.

#### Option A: Client Secret

Expand Down Expand Up @@ -234,6 +234,39 @@ For OBO to work, you need to configure API permissions in Azure:
3. Select the downstream API app registration
4. Add the required delegated permissions

### With Workload Identity Federation (Federated Credential)

If your MCP server runs in an environment with an external identity provider (e.g., SPIRE, GitHub Actions, or another Kubernetes cluster), you can use [workload identity federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation) instead of managing certificates or secrets. The external IdP issues a JWT that is passed directly to Entra ID as a federated credential.

#### Prerequisites

1. Configure a **federated identity credential** on your app registration:
- Go to **Certificates & secrets** → **Federated credentials** → **Add credential**
- Select the scenario (e.g., "Other issuer")
- Set the **Issuer** to your external IdP's OIDC issuer URL (e.g., `https://spire-server.example.com`)
- Set the **Subject identifier** to match the `sub` claim in the external JWT (e.g., `spiffe://example.com/mcp-server`)
- Set the **Audience** to match the `aud` claim (typically `api://AzureADTokenExchange`)
2. Ensure the external IdP writes a JWT to a file accessible by the MCP server (e.g., via SPIRE agent, Kubernetes projected volumes, or a sidecar)

#### Configuration

```toml
require_oauth = true
oauth_audience = "<CLIENT_ID>"
oauth_scopes = ["openid", "profile", "email"]

authorization_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"

# Token exchange with federated credential (workload identity federation)
token_exchange_strategy = "entra-obo"
sts_client_id = "<CLIENT_ID>"
sts_auth_style = "federated"
sts_federated_token_file = "/var/run/secrets/tokens/federated-token"
sts_scopes = ["api://<DOWNSTREAM_API_APP_ID>/.default"]
```

The MCP server reads the JWT from `sts_federated_token_file` on each token request, so token rotation by the external IdP is handled automatically.

## Step 3: Run the MCP Server

```bash
Expand Down Expand Up @@ -500,5 +533,7 @@ This way, the cluster's existing OIDC configuration is untouched, and the MCP se

- [Entra ID OAuth 2.0 Documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
- [Entra ID On-Behalf-Of Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)
- [Entra ID Workload Identity Federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation)
- [Entra ID Client Credentials with Federated Credential](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow#third-case-access-token-request-with-a-federated-credential)
- [Kubernetes OIDC Authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens)
- [Keycloak OIDC Setup](KEYCLOAK_OIDC_SETUP.md) - Alternative OIDC provider setup
3 changes: 2 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -489,9 +489,10 @@ Configure OAuth/OIDC authentication for HTTP mode deployments.
| `sts_audience` | string | `""` | Audience for STS token exchange. |
| `sts_scopes` | string[] | `[]` | Scopes for STS token exchange. |
| `token_exchange_strategy` | string | `""` | Token exchange strategy: `rfc8693`, `keycloak-v1`, or `entra-obo`. |
| `sts_auth_style` | string | `"params"` | How client credentials are sent: `params` (body), `header` (Basic Auth), or `assertion` (JWT). |
| `sts_auth_style` | string | `"params"` | How client credentials are sent: `params` (body), `header` (Basic Auth), `assertion` (JWT), or `federated` (external IdP token file). |
| `sts_client_cert_file` | string | `""` | Path to client certificate PEM file (for `assertion` auth style). |
| `sts_client_key_file` | string | `""` | Path to client private key PEM file (for `assertion` auth style). |
| `sts_federated_token_file` | string | `""` | Path to a JWT file from an external identity provider, e.g., SPIRE JWT-SVID (for `federated` auth style). |
| `cluster_auth_mode` | string | `""` | Cluster auth mode: `passthrough` (forward Authorization header when present, fall back to kubeconfig when absent) or `kubeconfig` (always use kubeconfig credentials). Defaults to `passthrough`. |
| `certificate_authority` | string | `""` | Path to CA certificate for validating authorization server connections. |
| `server_url` | string | `""` | Public URL of the MCP server (used for OAuth metadata). |
Expand Down
1 change: 1 addition & 0 deletions pkg/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ type StsConfigProvider interface {
GetStsAuthStyle() string
GetStsClientCertFile() string
GetStsClientKeyFile() string
GetStsFederatedTokenFile() string
GetCertificateAuthority() string
}

Expand Down
21 changes: 19 additions & 2 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,15 @@ type StaticConfig struct {
// "params" (default): client_id/secret in request body
// "header": HTTP Basic Authentication header
// "assertion": JWT client assertion (RFC 7523, for Entra ID certificate auth)
// "federated": JWT from an external identity provider file (workload identity federation)
StsAuthStyle string `toml:"sts_auth_style,omitempty"`
// StsClientCertFile is the path to the client certificate PEM file for JWT assertion auth
StsClientCertFile string `toml:"sts_client_cert_file,omitempty"`
// StsClientKeyFile is the path to the client private key PEM file for JWT assertion auth
StsClientKeyFile string `toml:"sts_client_key_file,omitempty"`
// StsFederatedTokenFile is the path to a file containing a JWT from an external identity
// provider (e.g., SPIRE JWT-SVID). Used with sts_auth_style="federated".
StsFederatedTokenFile string `toml:"sts_federated_token_file,omitempty"`
// ClusterAuthMode determines how the MCP server authenticates to the cluster.
// Valid values: "passthrough" (forward Authorization header, with optional exchange), "kubeconfig" (use kubeconfig credentials).
// If empty, defaults to passthrough: forwards the token when present, falls back to kubeconfig when absent.
Expand Down Expand Up @@ -424,6 +428,10 @@ func (c *StaticConfig) GetStsClientKeyFile() string {
return c.StsClientKeyFile
}

func (c *StaticConfig) GetStsFederatedTokenFile() string {
return c.StsFederatedTokenFile
}

func (c *StaticConfig) GetCertificateAuthority() string {
return c.CertificateAuthority
}
Expand Down Expand Up @@ -478,6 +486,7 @@ func (c *StaticConfig) Validate() error {
c.StsAuthStyle = strings.TrimSpace(c.StsAuthStyle)
c.StsClientCertFile = strings.TrimSpace(c.StsClientCertFile)
c.StsClientKeyFile = strings.TrimSpace(c.StsClientKeyFile)
c.StsFederatedTokenFile = strings.TrimSpace(c.StsFederatedTokenFile)
if output.FromString(c.ListOutput) == nil {
return fmt.Errorf("invalid output name: %s, valid names are: %s", c.ListOutput, strings.Join(output.Names, ", "))
}
Expand Down Expand Up @@ -583,9 +592,10 @@ func (c *StaticConfig) validateSkipJWTVerification() error {

// validateTokenExchange validates token-exchange-related fields:
// - token_exchange_strategy must be a known strategy (when registry is provided)
// - sts_auth_style must be one of "params", "header", "assertion"
// - sts_auth_style must be one of "params", "header", "assertion", "federated"
// - when sts_auth_style is "assertion", sts_client_cert_file and sts_client_key_file
// must both be set and reference existing files
// - when sts_auth_style is "federated", sts_federated_token_file must be set and exist
func (c *StaticConfig) validateTokenExchange() error {
if c.TokenExchangeStrategy != "" && len(c.tokenExchangeStrategies) > 0 {
if !slices.Contains(c.tokenExchangeStrategies, c.TokenExchangeStrategy) {
Expand All @@ -608,8 +618,15 @@ func (c *StaticConfig) validateTokenExchange() error {
if _, err := os.Stat(c.StsClientKeyFile); err != nil {
return fmt.Errorf("sts_client_key_file must be a valid file path: %w", err)
}
case tokenexchange.AuthStyleFederated:
if c.StsFederatedTokenFile == "" {
return fmt.Errorf("sts_federated_token_file is required when sts_auth_style is %q", tokenexchange.AuthStyleFederated)
}
if _, err := os.Stat(c.StsFederatedTokenFile); err != nil {
return fmt.Errorf("sts_federated_token_file must be a valid file path: %w", err)
}
default:
return fmt.Errorf("invalid sts_auth_style %q: must be %q, %q, or %q", c.StsAuthStyle, tokenexchange.AuthStyleParams, tokenexchange.AuthStyleHeader, tokenexchange.AuthStyleAssertion)
return fmt.Errorf("invalid sts_auth_style %q: must be %q, %q, %q, or %q", c.StsAuthStyle, tokenexchange.AuthStyleParams, tokenexchange.AuthStyleHeader, tokenexchange.AuthStyleAssertion, tokenexchange.AuthStyleFederated)
}
return nil
}
Expand Down
50 changes: 50 additions & 0 deletions pkg/config/validate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,56 @@ func (s *ValidateSuite) TestStsClientCertKey() {
})
}

func (s *ValidateSuite) TestStsFederatedTokenFile() {
s.Run("federated auth_style without token file is rejected", func() {
cfg := s.validConfig()
cfg.StsAuthStyle = "federated"
err := cfg.Validate()
s.Require().Error(err)
s.Contains(err.Error(), "sts_federated_token_file is required")
})

s.Run("federated auth_style with non-existent token file is rejected", func() {
cfg := s.validConfig()
cfg.StsAuthStyle = "federated"
cfg.StsFederatedTokenFile = "/nonexistent/token"
err := cfg.Validate()
s.Require().Error(err)
s.Contains(err.Error(), "sts_federated_token_file must be a valid file path")
})

s.Run("federated auth_style with valid token file is accepted", func() {
tmpDir := s.T().TempDir()
tokenPath := filepath.Join(tmpDir, "token")
s.Require().NoError(os.WriteFile(tokenPath, []byte("jwt-token"), 0600))

cfg := s.validConfig()
cfg.StsAuthStyle = "federated"
cfg.StsFederatedTokenFile = tokenPath
s.NoError(cfg.Validate())
})

s.Run("whitespace-only sts_federated_token_file is treated as empty", func() {
cfg := s.validConfig()
cfg.StsAuthStyle = "federated"
cfg.StsFederatedTokenFile = " "
err := cfg.Validate()
s.Require().Error(err)
s.Contains(err.Error(), "sts_federated_token_file is required")
s.Equal("", cfg.StsFederatedTokenFile, "whitespace should be trimmed")
})

s.Run("federated sts_auth_style is accepted in auth_style list", func() {
cfg := s.validConfig()
cfg.StsAuthStyle = "federated"
tmpDir := s.T().TempDir()
tokenPath := filepath.Join(tmpDir, "token")
s.Require().NoError(os.WriteFile(tokenPath, []byte("jwt-token"), 0600))
cfg.StsFederatedTokenFile = tokenPath
s.NoError(cfg.Validate())
})
}

func (s *ValidateSuite) TestConfirmationFallback() {
s.Run("empty fallback is accepted", func() {
cfg := s.validConfig()
Expand Down
19 changes: 10 additions & 9 deletions pkg/kubernetes/provider_token_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,15 +82,16 @@ func (p *tokenExchangingProvider) getOrBuildStsConfig(snap *oauth.Snapshot) *tok
}

cfg := &tokenexchange.TargetTokenExchangeConfig{
TokenURL: tokenURL,
ClientID: p.baseConfig.GetStsClientId(),
ClientSecret: p.baseConfig.GetStsClientSecret(),
Audience: p.baseConfig.GetStsAudience(),
Scopes: p.baseConfig.GetStsScopes(),
AuthStyle: authStyle,
ClientCertFile: p.baseConfig.GetStsClientCertFile(),
ClientKeyFile: p.baseConfig.GetStsClientKeyFile(),
CAFile: p.baseConfig.GetCertificateAuthority(),
TokenURL: tokenURL,
ClientID: p.baseConfig.GetStsClientId(),
ClientSecret: p.baseConfig.GetStsClientSecret(),
Audience: p.baseConfig.GetStsAudience(),
Scopes: p.baseConfig.GetStsScopes(),
AuthStyle: authStyle,
ClientCertFile: p.baseConfig.GetStsClientCertFile(),
ClientKeyFile: p.baseConfig.GetStsClientKeyFile(),
FederatedTokenFile: p.baseConfig.GetStsFederatedTokenFile(),
CAFile: p.baseConfig.GetCertificateAuthority(),
}
if err := cfg.Validate(); err != nil {
klog.Warningf("STS config validation failed, token exchange will be attempted per-request but will likely fail with the same error: %v", err)
Expand Down
19 changes: 10 additions & 9 deletions pkg/kubernetes/token_exchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,15 +165,16 @@ func strategyBasedTokenExchange(
}

cfg = &tokenexchange.TargetTokenExchangeConfig{
TokenURL: tokenURL,
ClientID: baseConfig.GetStsClientId(),
ClientSecret: baseConfig.GetStsClientSecret(),
Audience: baseConfig.GetStsAudience(),
Scopes: baseConfig.GetStsScopes(),
AuthStyle: authStyle,
ClientCertFile: baseConfig.GetStsClientCertFile(),
ClientKeyFile: baseConfig.GetStsClientKeyFile(),
CAFile: baseConfig.GetCertificateAuthority(),
TokenURL: tokenURL,
ClientID: baseConfig.GetStsClientId(),
ClientSecret: baseConfig.GetStsClientSecret(),
Audience: baseConfig.GetStsAudience(),
Scopes: baseConfig.GetStsScopes(),
AuthStyle: authStyle,
ClientCertFile: baseConfig.GetStsClientCertFile(),
ClientKeyFile: baseConfig.GetStsClientKeyFile(),
FederatedTokenFile: baseConfig.GetStsFederatedTokenFile(),
CAFile: baseConfig.GetCertificateAuthority(),
}
if err := cfg.Validate(); err != nil {
return ctx, fmt.Errorf("token exchange config validation: %w", err)
Expand Down
28 changes: 23 additions & 5 deletions pkg/tokenexchange/assertion.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package tokenexchange

import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
Expand Down Expand Up @@ -75,11 +77,18 @@ func loadCertificateAndKey(certFile, keyFile string) (*x509.Certificate, crypto.
}

if privateKey == nil {
return nil, nil, fmt.Errorf("failed to parse private key from %q (tried PKCS#8 and PKCS#1 formats)", keyFile)
return nil, nil, fmt.Errorf("failed to parse private key from %q (tried PKCS#8 and PKCS#1 formats; EC keys must be in PKCS#8 format, convert with: openssl pkcs8 -topk8 -nocrypt)", keyFile)
}

if _, ok := privateKey.(*rsa.PrivateKey); !ok {
return nil, nil, fmt.Errorf("unsupported key type %T from %q: only RSA keys are currently supported for JWT client assertions", privateKey, keyFile)
switch key := privateKey.(type) {
case *rsa.PrivateKey:
// RSA keys are supported
case *ecdsa.PrivateKey:
if key.Curve != elliptic.P256() && key.Curve != elliptic.P384() {
return nil, nil, fmt.Errorf("unsupported EC curve %v from %q: only P-256 and P-384 are supported", key.Curve.Params().Name, keyFile)
}
default:
return nil, nil, fmt.Errorf("unsupported key type %T from %q: only RSA and EC keys are supported for JWT client assertions", privateKey, keyFile)
}

return cert, privateKey, nil
Expand All @@ -95,11 +104,20 @@ func computeX5TS256(cert *x509.Certificate) string {

// getSignatureAlgorithm determines the jose.SignatureAlgorithm based on key type
func getSignatureAlgorithm(key crypto.Signer) (jose.SignatureAlgorithm, error) {
switch key.(type) {
switch k := key.(type) {
case *rsa.PrivateKey:
return jose.RS256, nil
case *ecdsa.PrivateKey:
switch k.Curve {
case elliptic.P256():
return jose.ES256, nil
case elliptic.P384():
return jose.ES384, nil
default:
return "", fmt.Errorf("unsupported EC curve: %v", k.Curve.Params().Name)
}
default:
return "", fmt.Errorf("unsupported key type: %T (only RSA keys are currently supported)", key)
return "", fmt.Errorf("unsupported key type: %T (only RSA and EC keys are supported)", key)
}
}

Expand Down
Loading