diff --git a/docs/ENTRA_ID_SETUP.md b/docs/ENTRA_ID_SETUP.md index aa98c5033..5b82ae2c9 100644 --- a/docs/ENTRA_ID_SETUP.md +++ b/docs/ENTRA_ID_SETUP.md @@ -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 @@ -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 = "" +oauth_scopes = ["openid", "profile", "email"] + +authorization_url = "https://login.microsoftonline.com//v2.0" + +# Token exchange with federated credential (workload identity federation) +token_exchange_strategy = "entra-obo" +sts_client_id = "" +sts_auth_style = "federated" +sts_federated_token_file = "/var/run/secrets/tokens/federated-token" +sts_scopes = ["api:///.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 @@ -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 diff --git a/docs/configuration.md b/docs/configuration.md index 1a48c1eaf..88c70e85e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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). | diff --git a/pkg/api/config.go b/pkg/api/config.go index 684441c4a..628004fc3 100644 --- a/pkg/api/config.go +++ b/pkg/api/config.go @@ -72,6 +72,7 @@ type StsConfigProvider interface { GetStsAuthStyle() string GetStsClientCertFile() string GetStsClientKeyFile() string + GetStsFederatedTokenFile() string GetCertificateAuthority() string } diff --git a/pkg/config/config.go b/pkg/config/config.go index 8113dd76c..31543cbec 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -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. @@ -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 } @@ -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, ", ")) } @@ -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) { @@ -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 } diff --git a/pkg/config/validate_test.go b/pkg/config/validate_test.go index 805566fc0..182898679 100644 --- a/pkg/config/validate_test.go +++ b/pkg/config/validate_test.go @@ -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() diff --git a/pkg/kubernetes/provider_token_exchange.go b/pkg/kubernetes/provider_token_exchange.go index f4150fb88..6ef855775 100644 --- a/pkg/kubernetes/provider_token_exchange.go +++ b/pkg/kubernetes/provider_token_exchange.go @@ -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) diff --git a/pkg/kubernetes/token_exchange.go b/pkg/kubernetes/token_exchange.go index ba669da5f..dce13d178 100644 --- a/pkg/kubernetes/token_exchange.go +++ b/pkg/kubernetes/token_exchange.go @@ -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) diff --git a/pkg/tokenexchange/assertion.go b/pkg/tokenexchange/assertion.go index 5c6b46615..8a910d3d5 100644 --- a/pkg/tokenexchange/assertion.go +++ b/pkg/tokenexchange/assertion.go @@ -2,6 +2,8 @@ package tokenexchange import ( "crypto" + "crypto/ecdsa" + "crypto/elliptic" "crypto/rsa" "crypto/sha256" "crypto/x509" @@ -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 @@ -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) } } diff --git a/pkg/tokenexchange/assertion_test.go b/pkg/tokenexchange/assertion_test.go index 7991af463..b53ef4bcc 100644 --- a/pkg/tokenexchange/assertion_test.go +++ b/pkg/tokenexchange/assertion_test.go @@ -267,24 +267,103 @@ func (s *AssertionTestSuite) TestValidate() { s.Error(err) s.Contains(err.Error(), "invalid auth_style") }) + + s.Run("federated style is valid with token file", func() { + cfg := &TargetTokenExchangeConfig{ + AuthStyle: AuthStyleFederated, + FederatedTokenFile: "/path/to/token", + } + err := cfg.Validate() + s.NoError(err) + }) + + s.Run("federated style requires token file", func() { + cfg := &TargetTokenExchangeConfig{ + AuthStyle: AuthStyleFederated, + } + err := cfg.Validate() + s.Error(err) + s.Contains(err.Error(), "federated_token_file is required") + }) } -func (s *AssertionTestSuite) TestLoadCertificateAndKeyRejectsECKeys() { +func (s *AssertionTestSuite) TestLoadCertificateAndKeyWithECKeys() { + s.Run("loads EC P-256 private key in PKCS8 format", func() { + ecCertFile, ecKeyFile := s.generateECCertAndKey(elliptic.P256()) + cert, key, err := loadCertificateAndKey(ecCertFile, ecKeyFile) + s.Require().NoError(err) + s.NotNil(cert) + s.IsType(&ecdsa.PrivateKey{}, key) + }) + + s.Run("loads EC P-384 private key in PKCS8 format", func() { + ecCertFile, ecKeyFile := s.generateECCertAndKey(elliptic.P384()) + cert, key, err := loadCertificateAndKey(ecCertFile, ecKeyFile) + s.Require().NoError(err) + s.NotNil(cert) + s.IsType(&ecdsa.PrivateKey{}, key) + }) + s.Run("rejects EC private key in SEC1 format", func() { ecKeyFile := s.generateECKeyFile("EC PRIVATE KEY", false) _, _, err := loadCertificateAndKey(s.certFile, ecKeyFile) s.Error(err) s.Contains(err.Error(), "failed to parse private key") }) +} - s.Run("rejects EC private key in PKCS8 format", func() { - ecKeyFile := s.generateECKeyFile("PRIVATE KEY", true) - _, _, err := loadCertificateAndKey(s.certFile, ecKeyFile) - s.Error(err) - s.Contains(err.Error(), "unsupported key type") +func (s *AssertionTestSuite) TestBuildClientAssertionWithECKey() { + s.Run("builds valid JWT with EC P-256 key", func() { + ecCertFile, ecKeyFile := s.generateECCertAndKey(elliptic.P256()) + clientID := "spiffe-client" + tokenURL := "https://login.microsoftonline.com/tenant/oauth2/v2.0/token" + + assertion, expiry, err := BuildClientAssertion(clientID, tokenURL, ecCertFile, ecKeyFile, 5*time.Minute) + s.Require().NoError(err) + s.NotEmpty(assertion) + s.True(expiry.After(time.Now())) + + token, err := jwt.ParseSigned(assertion, []jose.SignatureAlgorithm{jose.ES256}) + s.Require().NoError(err) + + var claims jwt.Claims + err = token.UnsafeClaimsWithoutVerification(&claims) + s.Require().NoError(err) + + s.Equal(clientID, claims.Issuer) + s.Equal(clientID, claims.Subject) + s.True(claims.Audience.Contains(tokenURL)) }) } +func (s *AssertionTestSuite) generateECCertAndKey(curve elliptic.Curve) (string, string) { + ecKey, err := ecdsa.GenerateKey(curve, rand.Reader) + s.Require().NoError(err) + + template := x509.Certificate{ + SerialNumber: big.NewInt(2), + Subject: pkix.Name{CommonName: "test-ec"}, + NotBefore: time.Now(), + NotAfter: time.Now().Add(time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &ecKey.PublicKey, ecKey) + s.Require().NoError(err) + + certFile := filepath.Join(s.tempDir, curve.Params().Name+"-cert.pem") + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + s.Require().NoError(os.WriteFile(certFile, certPEM, 0644)) + + keyBytes, err := x509.MarshalPKCS8PrivateKey(ecKey) + s.Require().NoError(err) + + keyFile := filepath.Join(s.tempDir, curve.Params().Name+"-key.pem") + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}) + s.Require().NoError(os.WriteFile(keyFile, keyPEM, 0600)) + + return certFile, keyFile +} + func (s *AssertionTestSuite) generateECKeyFile(pemType string, pkcs8 bool) string { ecKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) s.Require().NoError(err) @@ -343,6 +422,96 @@ func (s *AssertionTestSuite) TestInjectClientAuthWithAssertion() { }) } +func (s *AssertionTestSuite) TestInjectClientAuthWithFederated() { + s.Run("reads token from file and sets client assertion fields", func() { + tokenFile := filepath.Join(s.tempDir, "federated-token") + s.Require().NoError(os.WriteFile(tokenFile, []byte("eyJhbGciOiJSUzI1NiJ9.test.signature"), 0600)) + + cfg := &TargetTokenExchangeConfig{ + ClientID: "test-client", + AuthStyle: AuthStyleFederated, + FederatedTokenFile: tokenFile, + } + + data := url.Values{} + header := http.Header{} + err := injectClientAuth(cfg, data, header) + s.Require().NoError(err) + + s.Equal("test-client", data.Get(FormKeyClientID)) + s.Equal(ClientAssertionType, data.Get(FormKeyClientAssertionType)) + s.Equal("eyJhbGciOiJSUzI1NiJ9.test.signature", data.Get(FormKeyClientAssertion)) + s.Empty(data.Get(FormKeyClientSecret)) + s.Empty(header.Get(HeaderAuthorization)) + }) + + s.Run("trims whitespace from token file contents", func() { + tokenFile := filepath.Join(s.tempDir, "federated-token-ws") + s.Require().NoError(os.WriteFile(tokenFile, []byte(" eyJ0b2tlbi5qd3Q \n"), 0600)) + + cfg := &TargetTokenExchangeConfig{ + ClientID: "test-client", + AuthStyle: AuthStyleFederated, + FederatedTokenFile: tokenFile, + } + + data := url.Values{} + header := http.Header{} + err := injectClientAuth(cfg, data, header) + s.Require().NoError(err) + + s.Equal("eyJ0b2tlbi5qd3Q", data.Get(FormKeyClientAssertion)) + }) + + s.Run("returns error for missing token file", func() { + cfg := &TargetTokenExchangeConfig{ + ClientID: "test-client", + AuthStyle: AuthStyleFederated, + FederatedTokenFile: "/nonexistent/token", + } + + data := url.Values{} + header := http.Header{} + err := injectClientAuth(cfg, data, header) + s.Error(err) + s.Contains(err.Error(), "failed to read federated token file") + }) + + s.Run("returns error for empty token file", func() { + tokenFile := filepath.Join(s.tempDir, "empty-token") + s.Require().NoError(os.WriteFile(tokenFile, []byte(""), 0600)) + + cfg := &TargetTokenExchangeConfig{ + ClientID: "test-client", + AuthStyle: AuthStyleFederated, + FederatedTokenFile: tokenFile, + } + + data := url.Values{} + header := http.Header{} + err := injectClientAuth(cfg, data, header) + s.Error(err) + s.Contains(err.Error(), "is empty") + }) + + s.Run("returns error for whitespace-only token file", func() { + tokenFile := filepath.Join(s.tempDir, "ws-token") + s.Require().NoError(os.WriteFile(tokenFile, []byte(" \n\t \n"), 0600)) + + cfg := &TargetTokenExchangeConfig{ + ClientID: "test-client", + AuthStyle: AuthStyleFederated, + FederatedTokenFile: tokenFile, + } + + data := url.Values{} + header := http.Header{} + err := injectClientAuth(cfg, data, header) + s.Error(err) + s.Contains(err.Error(), "is empty") + }) +} + func TestAssertion(t *testing.T) { suite.Run(t, new(AssertionTestSuite)) } diff --git a/pkg/tokenexchange/config.go b/pkg/tokenexchange/config.go index f3e6545f7..4a1625d03 100644 --- a/pkg/tokenexchange/config.go +++ b/pkg/tokenexchange/config.go @@ -17,6 +17,9 @@ const ( AuthStyleHeader = "header" // AuthStyleAssertion sends a signed JWT client assertion (RFC 7523) AuthStyleAssertion = "assertion" + // AuthStyleFederated reads a JWT from an external identity provider token file + // and sends it as a client assertion (workload identity federation) + AuthStyleFederated = "federated" ) // TargetTokenExchangeConfig holds per-target token exchange configuration @@ -57,6 +60,10 @@ type TargetTokenExchangeConfig struct { // AssertionLifetime is the validity duration for generated JWT assertions // Defaults to 5 minutes if not specified AssertionLifetime time.Duration `toml:"assertion_lifetime,omitempty"` + // FederatedTokenFile is the path to a file containing a JWT from an external + // identity provider (e.g., SPIRE JWT-SVID). Used with AuthStyleFederated. + // The file is re-read on each token request to support token rotation. + FederatedTokenFile string `toml:"federated_token_file,omitempty"` // client is a http client configured to work with the IdP for this target client *http.Client `toml:"-"` @@ -82,8 +89,12 @@ func (c *TargetTokenExchangeConfig) Validate() error { if c.ClientKeyFile == "" { return fmt.Errorf("client_key_file is required when auth_style is %q", AuthStyleAssertion) } + case AuthStyleFederated: + if c.FederatedTokenFile == "" { + return fmt.Errorf("federated_token_file is required when auth_style is %q", AuthStyleFederated) + } default: - return fmt.Errorf("invalid auth_style %q: must be %q, %q, or %q", c.AuthStyle, AuthStyleParams, AuthStyleHeader, AuthStyleAssertion) + return fmt.Errorf("invalid auth_style %q: must be %q, %q, %q, or %q", c.AuthStyle, AuthStyleParams, AuthStyleHeader, AuthStyleAssertion, AuthStyleFederated) } return nil } diff --git a/pkg/tokenexchange/exchanger.go b/pkg/tokenexchange/exchanger.go index 6d02fc6e3..1245c9b2d 100644 --- a/pkg/tokenexchange/exchanger.go +++ b/pkg/tokenexchange/exchanger.go @@ -8,10 +8,12 @@ import ( "io" "net/http" "net/url" + "os" "strings" "time" "golang.org/x/oauth2" + "k8s.io/klog/v2" ) const ( @@ -43,6 +45,9 @@ const ( StrategyRFC8693 = "rfc8693" ) +// TokenExchanger performs a token exchange against an STS endpoint. +// The subjectToken parameter contains the user's OAuth token for strategies that +// exchange user tokens (rfc8693, keycloak-v1, entra-obo). type TokenExchanger interface { Exchange(ctx context.Context, cfg *TargetTokenExchangeConfig, subjectToken string) (*oauth2.Token, error) } @@ -65,6 +70,19 @@ func injectClientAuth(cfg *TargetTokenExchangeConfig, data url.Values, header ht data.Set(FormKeyClientID, cfg.ClientID) data.Set(FormKeyClientAssertionType, ClientAssertionType) data.Set(FormKeyClientAssertion, assertion) + case AuthStyleFederated: + tokenBytes, err := os.ReadFile(cfg.FederatedTokenFile) + if err != nil { + return fmt.Errorf("failed to read federated token file %q: %w", cfg.FederatedTokenFile, err) + } + token := strings.TrimSpace(string(tokenBytes)) + if token == "" { + return fmt.Errorf("federated token file %q is empty: the external identity provider may not have written a token yet", cfg.FederatedTokenFile) + } + klog.V(4).Infof("Read federated token from file %q (%d bytes)", cfg.FederatedTokenFile, len(token)) + data.Set(FormKeyClientID, cfg.ClientID) + data.Set(FormKeyClientAssertionType, ClientAssertionType) + data.Set(FormKeyClientAssertion, token) default: // AuthStyleParams or empty (default) data.Set(FormKeyClientID, cfg.ClientID) if cfg.ClientSecret != "" {