Skip to content

Commit 60b21ad

Browse files
authored
feat(tokenexchange): add EC key support and Entra ID federated auth style (#1147)
Add ECDSA P-256/P-384 key support for JWT client assertions, enabling SPIRE X.509-SVIDs in the OBO flow. Add new "federated" auth style that reads external IdP JWTs from a file for Entra ID workload identity federation. Signed-off-by: Nader Ziada <nziada@redhat.com>
1 parent a38a720 commit 60b21ad

11 files changed

Lines changed: 356 additions & 34 deletions

File tree

docs/ENTRA_ID_SETUP.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ From the app's **Overview** page, copy:
3434

3535
### Configure Client Credentials
3636

37-
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.
37+
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.
3838

3939
#### Option A: Client Secret
4040

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

237+
### With Workload Identity Federation (Federated Credential)
238+
239+
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.
240+
241+
#### Prerequisites
242+
243+
1. Configure a **federated identity credential** on your app registration:
244+
- Go to **Certificates & secrets****Federated credentials****Add credential**
245+
- Select the scenario (e.g., "Other issuer")
246+
- Set the **Issuer** to your external IdP's OIDC issuer URL (e.g., `https://spire-server.example.com`)
247+
- Set the **Subject identifier** to match the `sub` claim in the external JWT (e.g., `spiffe://example.com/mcp-server`)
248+
- Set the **Audience** to match the `aud` claim (typically `api://AzureADTokenExchange`)
249+
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)
250+
251+
#### Configuration
252+
253+
```toml
254+
require_oauth = true
255+
oauth_audience = "<CLIENT_ID>"
256+
oauth_scopes = ["openid", "profile", "email"]
257+
258+
authorization_url = "https://login.microsoftonline.com/<TENANT_ID>/v2.0"
259+
260+
# Token exchange with federated credential (workload identity federation)
261+
token_exchange_strategy = "entra-obo"
262+
sts_client_id = "<CLIENT_ID>"
263+
sts_auth_style = "federated"
264+
sts_federated_token_file = "/var/run/secrets/tokens/federated-token"
265+
sts_scopes = ["api://<DOWNSTREAM_API_APP_ID>/.default"]
266+
```
267+
268+
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.
269+
237270
## Step 3: Run the MCP Server
238271

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

501534
- [Entra ID OAuth 2.0 Documentation](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow)
502535
- [Entra ID On-Behalf-Of Flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-on-behalf-of-flow)
536+
- [Entra ID Workload Identity Federation](https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation)
537+
- [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)
503538
- [Kubernetes OIDC Authentication](https://kubernetes.io/docs/reference/access-authn-authz/authentication/#openid-connect-tokens)
504539
- [Keycloak OIDC Setup](KEYCLOAK_OIDC_SETUP.md) - Alternative OIDC provider setup

docs/configuration.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,9 +491,10 @@ Configure OAuth/OIDC authentication for HTTP mode deployments.
491491
| `sts_audience` | string | `""` | Audience for STS token exchange. |
492492
| `sts_scopes` | string[] | `[]` | Scopes for STS token exchange. |
493493
| `token_exchange_strategy` | string | `""` | Token exchange strategy: `rfc8693`, `keycloak-v1`, or `entra-obo`. |
494-
| `sts_auth_style` | string | `"params"` | How client credentials are sent: `params` (body), `header` (Basic Auth), or `assertion` (JWT). |
494+
| `sts_auth_style` | string | `"params"` | How client credentials are sent: `params` (body), `header` (Basic Auth), `assertion` (JWT), or `federated` (external IdP token file). |
495495
| `sts_client_cert_file` | string | `""` | Path to client certificate PEM file (for `assertion` auth style). |
496496
| `sts_client_key_file` | string | `""` | Path to client private key PEM file (for `assertion` auth style). |
497+
| `sts_federated_token_file` | string | `""` | Path to a JWT file from an external identity provider, e.g., SPIRE JWT-SVID (for `federated` auth style). |
497498
| `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`. |
498499
| `certificate_authority` | string | `""` | Path to CA certificate for validating authorization server connections. |
499500
| `server_url` | string | `""` | Public URL of the MCP server (used for OAuth metadata). |

pkg/api/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ type StsConfigProvider interface {
7272
GetStsAuthStyle() string
7373
GetStsClientCertFile() string
7474
GetStsClientKeyFile() string
75+
GetStsFederatedTokenFile() string
7576
GetCertificateAuthority() string
7677
}
7778

pkg/config/config.go

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,11 +93,15 @@ type StaticConfig struct {
9393
// "params" (default): client_id/secret in request body
9494
// "header": HTTP Basic Authentication header
9595
// "assertion": JWT client assertion (RFC 7523, for Entra ID certificate auth)
96+
// "federated": JWT from an external identity provider file (workload identity federation)
9697
StsAuthStyle string `toml:"sts_auth_style,omitempty"`
9798
// StsClientCertFile is the path to the client certificate PEM file for JWT assertion auth
9899
StsClientCertFile string `toml:"sts_client_cert_file,omitempty"`
99100
// StsClientKeyFile is the path to the client private key PEM file for JWT assertion auth
100101
StsClientKeyFile string `toml:"sts_client_key_file,omitempty"`
102+
// StsFederatedTokenFile is the path to a file containing a JWT from an external identity
103+
// provider (e.g., SPIRE JWT-SVID). Used with sts_auth_style="federated".
104+
StsFederatedTokenFile string `toml:"sts_federated_token_file,omitempty"`
101105
// ClusterAuthMode determines how the MCP server authenticates to the cluster.
102106
// Valid values: "passthrough" (forward Authorization header, with optional exchange), "kubeconfig" (use kubeconfig credentials).
103107
// If empty, defaults to passthrough: forwards the token when present, falls back to kubeconfig when absent.
@@ -425,6 +429,10 @@ func (c *StaticConfig) GetStsClientKeyFile() string {
425429
return c.StsClientKeyFile
426430
}
427431

432+
func (c *StaticConfig) GetStsFederatedTokenFile() string {
433+
return c.StsFederatedTokenFile
434+
}
435+
428436
func (c *StaticConfig) GetCertificateAuthority() string {
429437
return c.CertificateAuthority
430438
}
@@ -479,6 +487,7 @@ func (c *StaticConfig) Validate() error {
479487
c.StsAuthStyle = strings.TrimSpace(c.StsAuthStyle)
480488
c.StsClientCertFile = strings.TrimSpace(c.StsClientCertFile)
481489
c.StsClientKeyFile = strings.TrimSpace(c.StsClientKeyFile)
490+
c.StsFederatedTokenFile = strings.TrimSpace(c.StsFederatedTokenFile)
482491
if output.FromString(c.ListOutput) == nil {
483492
return fmt.Errorf("invalid output name: %s, valid names are: %s", c.ListOutput, strings.Join(output.Names, ", "))
484493
}
@@ -584,9 +593,10 @@ func (c *StaticConfig) validateSkipJWTVerification() error {
584593

585594
// validateTokenExchange validates token-exchange-related fields:
586595
// - token_exchange_strategy must be a known strategy (when registry is provided)
587-
// - sts_auth_style must be one of "params", "header", "assertion"
596+
// - sts_auth_style must be one of "params", "header", "assertion", "federated"
588597
// - when sts_auth_style is "assertion", sts_client_cert_file and sts_client_key_file
589598
// must both be set and reference existing files
599+
// - when sts_auth_style is "federated", sts_federated_token_file must be set and exist
590600
func (c *StaticConfig) validateTokenExchange() error {
591601
if c.TokenExchangeStrategy != "" && len(c.tokenExchangeStrategies) > 0 {
592602
if !slices.Contains(c.tokenExchangeStrategies, c.TokenExchangeStrategy) {
@@ -609,8 +619,15 @@ func (c *StaticConfig) validateTokenExchange() error {
609619
if _, err := os.Stat(c.StsClientKeyFile); err != nil {
610620
return fmt.Errorf("sts_client_key_file must be a valid file path: %w", err)
611621
}
622+
case tokenexchange.AuthStyleFederated:
623+
if c.StsFederatedTokenFile == "" {
624+
return fmt.Errorf("sts_federated_token_file is required when sts_auth_style is %q", tokenexchange.AuthStyleFederated)
625+
}
626+
if _, err := os.Stat(c.StsFederatedTokenFile); err != nil {
627+
return fmt.Errorf("sts_federated_token_file must be a valid file path: %w", err)
628+
}
612629
default:
613-
return fmt.Errorf("invalid sts_auth_style %q: must be %q, %q, or %q", c.StsAuthStyle, tokenexchange.AuthStyleParams, tokenexchange.AuthStyleHeader, tokenexchange.AuthStyleAssertion)
630+
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)
614631
}
615632
return nil
616633
}

pkg/config/validate_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,56 @@ func (s *ValidateSuite) TestStsClientCertKey() {
403403
})
404404
}
405405

406+
func (s *ValidateSuite) TestStsFederatedTokenFile() {
407+
s.Run("federated auth_style without token file is rejected", func() {
408+
cfg := s.validConfig()
409+
cfg.StsAuthStyle = "federated"
410+
err := cfg.Validate()
411+
s.Require().Error(err)
412+
s.Contains(err.Error(), "sts_federated_token_file is required")
413+
})
414+
415+
s.Run("federated auth_style with non-existent token file is rejected", func() {
416+
cfg := s.validConfig()
417+
cfg.StsAuthStyle = "federated"
418+
cfg.StsFederatedTokenFile = "/nonexistent/token"
419+
err := cfg.Validate()
420+
s.Require().Error(err)
421+
s.Contains(err.Error(), "sts_federated_token_file must be a valid file path")
422+
})
423+
424+
s.Run("federated auth_style with valid token file is accepted", func() {
425+
tmpDir := s.T().TempDir()
426+
tokenPath := filepath.Join(tmpDir, "token")
427+
s.Require().NoError(os.WriteFile(tokenPath, []byte("jwt-token"), 0600))
428+
429+
cfg := s.validConfig()
430+
cfg.StsAuthStyle = "federated"
431+
cfg.StsFederatedTokenFile = tokenPath
432+
s.NoError(cfg.Validate())
433+
})
434+
435+
s.Run("whitespace-only sts_federated_token_file is treated as empty", func() {
436+
cfg := s.validConfig()
437+
cfg.StsAuthStyle = "federated"
438+
cfg.StsFederatedTokenFile = " "
439+
err := cfg.Validate()
440+
s.Require().Error(err)
441+
s.Contains(err.Error(), "sts_federated_token_file is required")
442+
s.Equal("", cfg.StsFederatedTokenFile, "whitespace should be trimmed")
443+
})
444+
445+
s.Run("federated sts_auth_style is accepted in auth_style list", func() {
446+
cfg := s.validConfig()
447+
cfg.StsAuthStyle = "federated"
448+
tmpDir := s.T().TempDir()
449+
tokenPath := filepath.Join(tmpDir, "token")
450+
s.Require().NoError(os.WriteFile(tokenPath, []byte("jwt-token"), 0600))
451+
cfg.StsFederatedTokenFile = tokenPath
452+
s.NoError(cfg.Validate())
453+
})
454+
}
455+
406456
func (s *ValidateSuite) TestConfirmationFallback() {
407457
s.Run("empty fallback is accepted", func() {
408458
cfg := s.validConfig()

pkg/kubernetes/provider_token_exchange.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,16 @@ func (p *tokenExchangingProvider) getOrBuildStsConfig(snap *oauth.Snapshot) *tok
8282
}
8383

8484
cfg := &tokenexchange.TargetTokenExchangeConfig{
85-
TokenURL: tokenURL,
86-
ClientID: p.baseConfig.GetStsClientId(),
87-
ClientSecret: p.baseConfig.GetStsClientSecret(),
88-
Audience: p.baseConfig.GetStsAudience(),
89-
Scopes: p.baseConfig.GetStsScopes(),
90-
AuthStyle: authStyle,
91-
ClientCertFile: p.baseConfig.GetStsClientCertFile(),
92-
ClientKeyFile: p.baseConfig.GetStsClientKeyFile(),
93-
CAFile: p.baseConfig.GetCertificateAuthority(),
85+
TokenURL: tokenURL,
86+
ClientID: p.baseConfig.GetStsClientId(),
87+
ClientSecret: p.baseConfig.GetStsClientSecret(),
88+
Audience: p.baseConfig.GetStsAudience(),
89+
Scopes: p.baseConfig.GetStsScopes(),
90+
AuthStyle: authStyle,
91+
ClientCertFile: p.baseConfig.GetStsClientCertFile(),
92+
ClientKeyFile: p.baseConfig.GetStsClientKeyFile(),
93+
FederatedTokenFile: p.baseConfig.GetStsFederatedTokenFile(),
94+
CAFile: p.baseConfig.GetCertificateAuthority(),
9495
}
9596
if err := cfg.Validate(); err != nil {
9697
klog.Warningf("STS config validation failed, token exchange will be attempted per-request but will likely fail with the same error: %v", err)

pkg/kubernetes/token_exchange.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,15 +165,16 @@ func strategyBasedTokenExchange(
165165
}
166166

167167
cfg = &tokenexchange.TargetTokenExchangeConfig{
168-
TokenURL: tokenURL,
169-
ClientID: baseConfig.GetStsClientId(),
170-
ClientSecret: baseConfig.GetStsClientSecret(),
171-
Audience: baseConfig.GetStsAudience(),
172-
Scopes: baseConfig.GetStsScopes(),
173-
AuthStyle: authStyle,
174-
ClientCertFile: baseConfig.GetStsClientCertFile(),
175-
ClientKeyFile: baseConfig.GetStsClientKeyFile(),
176-
CAFile: baseConfig.GetCertificateAuthority(),
168+
TokenURL: tokenURL,
169+
ClientID: baseConfig.GetStsClientId(),
170+
ClientSecret: baseConfig.GetStsClientSecret(),
171+
Audience: baseConfig.GetStsAudience(),
172+
Scopes: baseConfig.GetStsScopes(),
173+
AuthStyle: authStyle,
174+
ClientCertFile: baseConfig.GetStsClientCertFile(),
175+
ClientKeyFile: baseConfig.GetStsClientKeyFile(),
176+
FederatedTokenFile: baseConfig.GetStsFederatedTokenFile(),
177+
CAFile: baseConfig.GetCertificateAuthority(),
177178
}
178179
if err := cfg.Validate(); err != nil {
179180
return ctx, fmt.Errorf("token exchange config validation: %w", err)

pkg/tokenexchange/assertion.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package tokenexchange
22

33
import (
44
"crypto"
5+
"crypto/ecdsa"
6+
"crypto/elliptic"
57
"crypto/rsa"
68
"crypto/sha256"
79
"crypto/x509"
@@ -75,11 +77,18 @@ func loadCertificateAndKey(certFile, keyFile string) (*x509.Certificate, crypto.
7577
}
7678

7779
if privateKey == nil {
78-
return nil, nil, fmt.Errorf("failed to parse private key from %q (tried PKCS#8 and PKCS#1 formats)", keyFile)
80+
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)
7981
}
8082

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

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

96105
// getSignatureAlgorithm determines the jose.SignatureAlgorithm based on key type
97106
func getSignatureAlgorithm(key crypto.Signer) (jose.SignatureAlgorithm, error) {
98-
switch key.(type) {
107+
switch k := key.(type) {
99108
case *rsa.PrivateKey:
100109
return jose.RS256, nil
110+
case *ecdsa.PrivateKey:
111+
switch k.Curve {
112+
case elliptic.P256():
113+
return jose.ES256, nil
114+
case elliptic.P384():
115+
return jose.ES384, nil
116+
default:
117+
return "", fmt.Errorf("unsupported EC curve: %v", k.Curve.Params().Name)
118+
}
101119
default:
102-
return "", fmt.Errorf("unsupported key type: %T (only RSA keys are currently supported)", key)
120+
return "", fmt.Errorf("unsupported key type: %T (only RSA and EC keys are supported)", key)
103121
}
104122
}
105123

0 commit comments

Comments
 (0)