diff --git a/go.mod b/go.mod index 8e94b3b..b07fdf8 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,9 @@ require ( github.com/google/uuid v1.3.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/icholy/replace v0.6.0 // indirect github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 0f4f2a8..ffffba4 100644 --- a/go.sum +++ b/go.sum @@ -13,17 +13,22 @@ github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027 h1:1L0aalTpPz7YlMx github.com/elazarl/goproxy v0.0.0-20230731152917-f99041a5c027/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM= github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/icholy/replace v0.6.0 h1:EBiD2pGqZIOJAbEaf/5GVRaD/Pmbb4n+K3LrBdXd4dw= +github.com/icholy/replace v0.6.0/go.mod h1:zzi8pxElj2t/5wHHHYmH45D+KxytX/t4w3ClY5nlK+g= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -37,13 +42,24 @@ github.com/vmihailenco/msgpack/v5 v5.3.5 h1:5gO0H1iULLWGhs2H5tbAHIZTV8/cYafcFOr9 github.com/vmihailenco/msgpack/v5 v5.3.5/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1 h1:MGwJjxBy0HJshjDNfLsYO8xppfqWlA5ZT9OhtUUhTNw= golang.org/x/exp v0.0.0-20230713183714-613f0c0eb8a1/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= diff --git a/processor.go b/processor.go index e874f91..c474a5c 100644 --- a/processor.go +++ b/processor.go @@ -16,6 +16,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" + "github.com/icholy/replace" "golang.org/x/exp/slices" ) @@ -37,6 +38,10 @@ const ( // ParamSubtoken optionally specifies which subtoken should be used. // Default is SubtokenAccessToken. ParamSubtoken = "st" + + // ParamPlaceholder specifies the placeholder pattern to replace in the + // request body with the token value. Used by InjectBodyProcessor. + ParamPlaceholder = "placeholder" ) const ( @@ -54,7 +59,9 @@ type ProcessorConfig interface { type wireProcessor struct { InjectProcessorConfig *InjectProcessorConfig `json:"inject_processor,omitempty"` InjectHMACProcessorConfig *InjectHMACProcessorConfig `json:"inject_hmac_processor,omitempty"` + InjectBodyProcessorConfig *InjectBodyProcessorConfig `json:"inject_body_processor,omitempty"` OAuthProcessorConfig *OAuthProcessorConfig `json:"oauth2_processor,omitempty"` + OAuthBodyProcessorConfig *OAuthBodyProcessorConfig `json:"oauth2_body_processor,omitempty"` Sigv4ProcessorConfig *Sigv4ProcessorConfig `json:"sigv4_processor,omitempty"` MultiProcessorConfig *MultiProcessorConfig `json:"multi_processor,omitempty"` } @@ -65,8 +72,12 @@ func newWireProcessor(p ProcessorConfig) (wireProcessor, error) { return wireProcessor{InjectProcessorConfig: p}, nil case *InjectHMACProcessorConfig: return wireProcessor{InjectHMACProcessorConfig: p}, nil + case *InjectBodyProcessorConfig: + return wireProcessor{InjectBodyProcessorConfig: p}, nil case *OAuthProcessorConfig: return wireProcessor{OAuthProcessorConfig: p}, nil + case *OAuthBodyProcessorConfig: + return wireProcessor{OAuthBodyProcessorConfig: p}, nil case *Sigv4ProcessorConfig: return wireProcessor{Sigv4ProcessorConfig: p}, nil case *MultiProcessorConfig: @@ -88,10 +99,18 @@ func (wp *wireProcessor) getProcessorConfig() (ProcessorConfig, error) { np += 1 p = wp.InjectHMACProcessorConfig } + if wp.InjectBodyProcessorConfig != nil { + np += 1 + p = wp.InjectBodyProcessorConfig + } if wp.OAuthProcessorConfig != nil { np += 1 p = wp.OAuthProcessorConfig } + if wp.OAuthBodyProcessorConfig != nil { + np += 1 + p = wp.OAuthBodyProcessorConfig + } if wp.Sigv4ProcessorConfig != nil { np += 1 p = wp.Sigv4ProcessorConfig @@ -198,6 +217,51 @@ func (c *InjectHMACProcessorConfig) StripHazmat() ProcessorConfig { } } +type InjectBodyProcessorConfig struct { + Token string `json:"token"` + Placeholder string `json:"placeholder,omitempty"` +} + +var _ ProcessorConfig = new(InjectBodyProcessorConfig) + +func (c *InjectBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { + if c.Token == "" { + return nil, errors.New("missing token") + } + + // Get placeholder from params or use config default or fallback + placeholder := c.Placeholder + if paramPlaceholder, ok := params[ParamPlaceholder]; ok { + placeholder = paramPlaceholder + } + if placeholder == "" { + placeholder = "{{ACCESS_TOKEN}}" + } + + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, c.Token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil +} + +func (c *InjectBodyProcessorConfig) StripHazmat() ProcessorConfig { + return &InjectBodyProcessorConfig{ + Token: redactedStr, + Placeholder: c.Placeholder, + } +} + type OAuthProcessorConfig struct { Token *OAuthToken `json:"token"` } @@ -219,6 +283,27 @@ func (c *OAuthProcessorConfig) Processor(params map[string]string) (RequestProce return nil, errors.New("missing token") } + // Check if placeholder parameter is present for body injection + if placeholder, ok := params[ParamPlaceholder]; ok && placeholder != "" { + // Inject token into request body by replacing placeholder + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil + } + + // Default behavior: inject into Authorization header return func(r *http.Request) error { r.Header.Set("Authorization", "Bearer "+token) return nil @@ -234,6 +319,59 @@ func (c *OAuthProcessorConfig) StripHazmat() ProcessorConfig { } } +type OAuthBodyProcessorConfig struct { + Token *OAuthToken `json:"token"` + Placeholder string `json:"placeholder,omitempty"` +} + +var _ ProcessorConfig = (*OAuthBodyProcessorConfig)(nil) + +func (c *OAuthBodyProcessorConfig) Processor(params map[string]string) (RequestProcessor, error) { + token := c.Token.AccessToken + if params[ParamSubtoken] == SubtokenRefresh { + token = c.Token.RefreshToken + } + + if token == "" { + return nil, errors.New("missing token") + } + + // Get placeholder from params or use config default or fallback + placeholder := c.Placeholder + if paramPlaceholder, ok := params[ParamPlaceholder]; ok { + placeholder = paramPlaceholder + } + if placeholder == "" { + placeholder = "{{ACCESS_TOKEN}}" + } + + return func(r *http.Request) error { + if r.Body == nil { + return nil + } + + // Use streaming replacement to avoid loading entire body into memory + chain := replace.Chain(r.Body, replace.String(placeholder, token)) + + // Set body to the replacement chain for true streaming with chunked encoding + // Setting ContentLength to 0 triggers chunked transfer encoding + r.Body = io.NopCloser(chain) + r.ContentLength = 0 + + return nil + }, nil +} + +func (c *OAuthBodyProcessorConfig) StripHazmat() ProcessorConfig { + return &OAuthBodyProcessorConfig{ + Token: &OAuthToken{ + AccessToken: redactedStr, + RefreshToken: redactedStr, + }, + Placeholder: c.Placeholder, + } +} + type Sigv4ProcessorConfig struct { AccessKey string `json:"access_key"` SecretKey string `json:"secret_key"` diff --git a/processor_test.go b/processor_test.go index d3e6105..48cb03d 100644 --- a/processor_test.go +++ b/processor_test.go @@ -2,6 +2,7 @@ package tokenizer import ( "bytes" + "io" "net/http" "strings" "testing" @@ -103,3 +104,287 @@ func TestDstProcessor(t *testing.T) { assertResult("error", DstProcessor{AllowedDst: []string{"Bar"}}, map[string]string{ParamDst: "Foo"}) assertResult("error", DstProcessor{Dst: "Bar"}, map[string]string{ParamDst: "Foo"}) } + +func TestInjectBodyProcessorConfig(t *testing.T) { + tests := []struct { + name string + config InjectBodyProcessorConfig + params map[string]string + requestBody string + expectedBody string + }{ + { + name: "simple replacement with default placeholder", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "multiple occurrences", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"access": "{{ACCESS_TOKEN}}", "refresh": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"access": "secret-token-123", "refresh": "secret-token-123"}`, + }, + { + name: "custom placeholder from params", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "custom placeholder from config", + config: InjectBodyProcessorConfig{Token: "secret-token-123", Placeholder: "[[TOKEN]]"}, + params: map[string]string{}, + requestBody: `{"token": "[[TOKEN]]"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "param overrides config placeholder", + config: InjectBodyProcessorConfig{Token: "secret-token-123", Placeholder: "[[TOKEN]]"}, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "secret-token-123"}`, + }, + { + name: "no matches - placeholder not found", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: `{"token": "different-placeholder"}`, + expectedBody: `{"token": "different-placeholder"}`, + }, + { + name: "empty body", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: "", + expectedBody: "", + }, + { + name: "large body with streaming", + config: InjectBodyProcessorConfig{Token: "secret-token-123"}, + params: map[string]string{}, + requestBody: strings.Repeat("{{ACCESS_TOKEN}}", 10000), + expectedBody: strings.Repeat("secret-token-123", 10000), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = http.NoBody + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.expectedBody == "" && (tt.requestBody == "" || tt.requestBody == "nil") { + return + } + + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + }) + } +} + +func TestOAuthProcessorConfigBodyInjection(t *testing.T) { + tests := []struct { + name string + config OAuthProcessorConfig + params map[string]string + requestBody string + expectedBody string + checkHeader bool + }{ + { + name: "body injection with placeholder parameter", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamPlaceholder: "{{ACCESS_TOKEN}}"}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "access-123"}`, + checkHeader: false, + }, + { + name: "body injection with refresh token", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamPlaceholder: "{{TOKEN}}", ParamSubtoken: SubtokenRefresh}, + requestBody: `{"token": "{{TOKEN}}"}`, + expectedBody: `{"token": "refresh-456"}`, + checkHeader: false, + }, + { + name: "header injection when no placeholder", + config: OAuthProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + checkHeader: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.checkHeader { + // Header injection: verify Authorization header is set and body is unchanged + assert.Equal(t, "Bearer access-123", req.Header.Get("Authorization")) + // Body should be unchanged (nil or original) + if tt.requestBody != "" { + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.requestBody, bodyBytes.String()) + } + } else { + // Body injection: verify body is modified and Authorization header is NOT set + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + // Authorization header should NOT be set during body injection + assert.Equal(t, "", req.Header.Get("Authorization")) + } + }) + } +} + +func TestOAuthBodyProcessorConfig(t *testing.T) { + tests := []struct { + name string + config OAuthBodyProcessorConfig + params map[string]string + requestBody string + expectedBody string + }{ + { + name: "simple replacement with access token", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "replacement with refresh token", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123", RefreshToken: "refresh-456"}, + }, + params: map[string]string{ParamSubtoken: SubtokenRefresh}, + requestBody: `{"token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"token": "refresh-456"}`, + }, + { + name: "custom placeholder from params", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{ParamPlaceholder: "<>"}, + requestBody: `{"token": "<>"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "custom placeholder from config", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + Placeholder: "[[TOKEN]]", + }, + params: map[string]string{}, + requestBody: `{"token": "[[TOKEN]]"}`, + expectedBody: `{"token": "access-123"}`, + }, + { + name: "multiple occurrences", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: `{"client_id": "id", "client_secret": "secret", "token": "{{ACCESS_TOKEN}}", "token_type_hint": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"client_id": "id", "client_secret": "secret", "token": "access-123", "token_type_hint": "access-123"}`, + }, + { + name: "nil body", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: "", + }, + { + name: "large body with streaming", + config: OAuthBodyProcessorConfig{ + Token: &OAuthToken{AccessToken: "access-123"}, + }, + params: map[string]string{}, + requestBody: `{"data": "` + strings.Repeat("x", 50000) + `", "token": "{{ACCESS_TOKEN}}"}`, + expectedBody: `{"data": "` + strings.Repeat("x", 50000) + `", "token": "access-123"}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + proc, err := tt.config.Processor(tt.params) + assert.NoError(t, err) + + req := &http.Request{ + Header: make(http.Header), + } + if tt.requestBody != "" { + req.Body = stringToReadCloser(tt.requestBody) + } + + err = proc(req) + assert.NoError(t, err) + + if tt.requestBody == "" { + return + } + + bodyBytes := new(bytes.Buffer) + _, err = bodyBytes.ReadFrom(req.Body) + assert.NoError(t, err) + assert.Equal(t, tt.expectedBody, bodyBytes.String()) + // ContentLength is 0 for chunked transfer encoding + assert.Equal(t, int64(0), req.ContentLength) + }) + } +} + +// Helper function to convert string to io.ReadCloser +func stringToReadCloser(s string) io.ReadCloser { + return io.NopCloser(strings.NewReader(s)) +}