diff --git a/README.md b/README.md index b23e801..d377821 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,5 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file ## Contributing Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. + +For suspected vulnerabilities, follow the private reporting instructions in [SECURITY.md](SECURITY.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4c83170 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security policy + +Please report suspected vulnerabilities in tokenex privately before opening a +public issue. Email **info@riptides.io** with the subject `tokenex security +report`, the affected version, a description, and steps to reproduce. Please +avoid sending live credentials or sensitive customer data. We will coordinate +disclosure and a fix with you. + +For usage questions and non-security bugs, use GitHub Issues. diff --git a/docs/rfc8693.md b/docs/rfc8693.md index ebf9a2f..3162b33 100644 --- a/docs/rfc8693.md +++ b/docs/rfc8693.md @@ -33,11 +33,11 @@ for cred := range credCh { } ``` -The request body is JSON. The subject token type defaults to `urn:ietf:params:oauth:token-type:jwt` (`WithSubjectTokenType` changes it). `WithActorTokenProvider` adds an actor token for delegation, and `WithRequestedTokenType` and `WithAdditionalFields` cover endpoint-specific parameters. +The request body is form encoded by default, as RFC 8693 requires. For an endpoint that explicitly requires JSON, pass `WithBodyFormat(rfc8693.BodyFormatJSON)`. This changes the default from JSON in v0.7.0, so existing callers of `GetCredentials` using a JSON-only endpoint must add that option when upgrading. The subject token type defaults to `urn:ietf:params:oauth:token-type:jwt` (`WithSubjectTokenType` changes it). `WithActorTokenProvider` adds an actor token for delegation, and `WithRequestedTokenType` and `WithAdditionalFields` cover endpoint-specific parameters in either format. ## OpenAI Workload Identity Federation -`rfc8693.GetOpenAICredentials` exchanges the identity token at OpenAI's token endpoint (`https://auth.openai.com/oauth/token`), using the same request shape as the [openai-go workload identity support](https://github.com/openai/openai-go/blob/main/auth/workloadidentity.go). `ClientID` is optional. +`rfc8693.GetOpenAICredentials` exchanges the identity token at OpenAI's token endpoint (`https://auth.openai.com/oauth/token`), using the JSON request shape from the [openai-go workload identity support](https://github.com/openai/openai-go/blob/main/auth/workloadidentity.go). The wrapper selects JSON automatically. `ClientID` is optional. ```go credCh, err := rfc8693.GetOpenAICredentials(ctx, logr.Discard(), idTokenProvider, rfc8693.OpenAIWIFConfig{ diff --git a/pkg/rfc8693/openai.go b/pkg/rfc8693/openai.go index 9b6283d..2d3e419 100644 --- a/pkg/rfc8693/openai.go +++ b/pkg/rfc8693/openai.go @@ -54,9 +54,11 @@ func GetOpenAICredentials( fields["client_id"] = wif.ClientID } - all := make([]option.Option, 0, len(opts)+2) + all := make([]option.Option, 0, len(opts)+3) all = append(all, WithSubjectTokenType(TokenTypeJWT)) all = append(all, opts...) + // OpenAI's workload identity endpoint expects JSON rather than RFC 8693's form body. + all = append(all, WithBodyFormat(BodyFormatJSON)) all = append(all, WithAdditionalFields(fields)) return p.GetCredentials(ctx, openaiTokenEndpoint, subjectTokenProvider, all...) diff --git a/pkg/rfc8693/option.go b/pkg/rfc8693/option.go index fcbd26c..cd65860 100644 --- a/pkg/rfc8693/option.go +++ b/pkg/rfc8693/option.go @@ -95,6 +95,15 @@ func WithAdditionalFields(fields map[string]string) option.Option { }) } +// WithBodyFormat selects the token request encoding. The default is +// BodyFormatForm, as required by RFC 8693. Use BodyFormatJSON for endpoints +// that explicitly require JSON. +func WithBodyFormat(format BodyFormat) option.Option { + return withCredentialsOption(func(c *credentialsConfig) { + c.bodyFormat = format + }) +} + func WithHTTPClient(client *http.Client) option.Option { return withCredentialsOption(func(c *credentialsConfig) { c.httpClient = client diff --git a/pkg/rfc8693/rfc8693.go b/pkg/rfc8693/rfc8693.go index e492d5c..c91b223 100644 --- a/pkg/rfc8693/rfc8693.go +++ b/pkg/rfc8693/rfc8693.go @@ -22,6 +22,7 @@ import ( "io" "maps" "net/http" + "net/url" "strings" "time" @@ -66,6 +67,16 @@ type CredentialsProvider interface { GetCredentials(ctx context.Context, tokenEndpointURL string, subjectTokenProvider token.IdentityTokenProvider, opts ...option.Option) (<-chan Credential, error) } +// BodyFormat controls how a token exchange request is encoded. +type BodyFormat int + +const ( + // BodyFormatForm uses the encoding required by RFC 8693. + BodyFormatForm BodyFormat = iota + // BodyFormatJSON is used by endpoints such as OpenAI's workload identity endpoint. + BodyFormatJSON +) + var _ CredentialsProvider = &credentialsProvider{} type credentialsConfig struct { @@ -78,6 +89,7 @@ type credentialsConfig struct { audience string scopes []string additionalFields map[string]string + bodyFormat BodyFormat httpClient *http.Client tracerProvider trace.TracerProvider @@ -306,17 +318,33 @@ func exchangeToken(ctx context.Context, client *http.Client, cfg *credentialsCon maps.Copy(body, cfg.additionalFields) - encoded, err := json.Marshal(body) - if err != nil { - return nil, errors.WrapIf(err, "could not encode token request") + var ( + requestBody io.Reader + contentType string + ) + + if cfg.bodyFormat == BodyFormatJSON { + encoded, err := json.Marshal(body) + if err != nil { + return nil, errors.WrapIf(err, "could not encode token request") + } + requestBody = bytes.NewReader(encoded) + contentType = "application/json" + } else { + values := url.Values{} + for key, value := range body { + values.Set(key, value) + } + requestBody = strings.NewReader(values.Encode()) + contentType = "application/x-www-form-urlencoded" } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.tokenEndpointURL, bytes.NewReader(encoded)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.tokenEndpointURL, requestBody) if err != nil { return nil, errors.WrapIf(err, "could not build token request") } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", contentType) req.Header.Set("Accept", "application/json") resp, err := client.Do(req) diff --git a/pkg/rfc8693/rfc8693_test.go b/pkg/rfc8693/rfc8693_test.go index f792d12..c8c97e9 100644 --- a/pkg/rfc8693/rfc8693_test.go +++ b/pkg/rfc8693/rfc8693_test.go @@ -14,6 +14,7 @@ import ( "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" "go.riptides.io/tokenex/pkg/credential" "go.riptides.io/tokenex/pkg/option" @@ -105,14 +106,11 @@ func TestExchangeToken_Success(t *testing.T) { srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, http.MethodPost, r.Method) - assert.Equal(t, "application/json", r.Header.Get("Content-Type")) - - var body map[string]string - - assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) - assert.Equal(t, GrantType, body["grant_type"]) - assert.Equal(t, "my-subject-token", body["subject_token"]) - assert.Equal(t, string(TokenTypeJWT), body["subject_token_type"]) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, GrantType, r.PostForm.Get("grant_type")) + assert.Equal(t, "my-subject-token", r.PostForm.Get("subject_token")) + assert.Equal(t, string(TokenTypeJWT), r.PostForm.Get("subject_token_type")) successHandler(wantToken, 3600)(w, r) }) @@ -129,6 +127,31 @@ func TestExchangeToken_Success(t *testing.T) { require.False(t, tok.Expiry.IsZero()) } +func TestExchangeToken_JSONBody(t *testing.T) { + t.Parallel() + + srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + var body map[string]string + assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "subject", body["subject_token"]) + successHandler("json-token", 60)(w, r) + }) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + t.Cleanup(cancel) + cp, err := NewCredentialsProvider(ctx, newLogger()) + require.NoError(t, err) + ch, err := cp.GetCredentials(ctx, srv.URL, &staticProvider{tok: "subject"}, + WithBodyFormat(BodyFormatJSON), WithHTTPClient(srv.Client())) + require.NoError(t, err) + cred := <-ch + require.NoError(t, cred.Err) + tok, ok := cred.Credential.(*credential.Oauth2Creds) + require.True(t, ok) + assert.Equal(t, "json-token", tok.AccessToken) +} + func TestExchangeToken_NoExpiry(t *testing.T) { t.Parallel() @@ -148,12 +171,9 @@ func TestExchangeToken_WithActorToken(t *testing.T) { t.Parallel() srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - var body map[string]string - - json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck - - assert.Equal(t, "actor-jwt", body["actor_token"]) - assert.Equal(t, string(TokenTypeJWT), body["actor_token_type"]) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "actor-jwt", r.PostForm.Get("actor_token")) + assert.Equal(t, string(TokenTypeJWT), r.PostForm.Get("actor_token_type")) successHandler("tok", 60)(w, r) }) @@ -171,10 +191,8 @@ func TestExchangeToken_WithAdditionalFields(t *testing.T) { t.Parallel() srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - var body map[string]string - - json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck - assert.Equal(t, "idp-123", body["identity_provider_id"]) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "idp-123 & team", r.PostForm.Get("identity_provider_id")) successHandler("tok", 60)(w, r) }) @@ -182,7 +200,7 @@ func TestExchangeToken_WithAdditionalFields(t *testing.T) { cfg := &credentialsConfig{ tokenEndpointURL: srv.URL, subjectTokenType: TokenTypeJWT, - additionalFields: map[string]string{"identity_provider_id": "idp-123"}, + additionalFields: map[string]string{"identity_provider_id": "idp-123 & team"}, } _, err := exchangeToken(t.Context(), srv.Client(), cfg, "subject", "") @@ -193,11 +211,9 @@ func TestExchangeToken_ScopeAndAudience(t *testing.T) { t.Parallel() srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { - var body map[string]string - - json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck - assert.Equal(t, "read write", body["scope"]) - assert.Equal(t, "my-service", body["audience"]) + assert.NoError(t, r.ParseForm()) + assert.Equal(t, "read write", r.PostForm.Get("scope")) + assert.Equal(t, "my-service", r.PostForm.Get("audience")) successHandler("tok", 60)(w, r) }) @@ -292,6 +308,7 @@ func TestGetOpenAICredentials_RequestShape(t *testing.T) { t.Parallel() srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) var body map[string]string json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck @@ -323,6 +340,7 @@ func TestGetOpenAICredentials_RequestShape(t *testing.T) { subjectProvider, wif, WithTokenEndpointURL(srv.URL), + WithBodyFormat(BodyFormatForm), // OpenAI must still use JSON. WithHTTPClient(srv.Client()), ) require.NoError(t, err) @@ -336,6 +354,7 @@ func TestGetOpenAICredentials_NoClientID(t *testing.T) { t.Parallel() srv := newTestServer(t, func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) var body map[string]string json.NewDecoder(r.Body).Decode(&body) //nolint:errcheck @@ -369,3 +388,12 @@ func TestGetOpenAICredentials_NoClientID(t *testing.T) { cred := <-ch require.NoError(t, cred.Err) } + +func TestFetchSpanConfigAttrs_BodyFormat(t *testing.T) { + t.Parallel() + + for format, want := range map[BodyFormat]string{BodyFormatForm: "form", BodyFormatJSON: "json"} { + attrs := fetchSpanConfigAttrs(&credentialsConfig{bodyFormat: format}, "correlation") + assert.Contains(t, attrs, attribute.String("cfg.body_format", want)) + } +} diff --git a/pkg/rfc8693/telemetry.go b/pkg/rfc8693/telemetry.go index 7eaf86e..95337ef 100644 --- a/pkg/rfc8693/telemetry.go +++ b/pkg/rfc8693/telemetry.go @@ -23,12 +23,22 @@ var allowedAdditionalFieldKeys = []string{ "client_id", } +func bodyFormatString(f BodyFormat) string { + switch f { + case BodyFormatJSON: + return "json" + default: + return "form" + } +} + func fetchSpanConfigAttrs(cfg *credentialsConfig, correlationID string) []attribute.KeyValue { - attrs := make([]attribute.KeyValue, 0, 7+len(allowedAdditionalFieldKeys)) + attrs := make([]attribute.KeyValue, 0, 8+len(allowedAdditionalFieldKeys)) attrs = append(attrs, attribute.String("cfg.token_endpoint_url", cfg.tokenEndpointURL), attribute.StringSlice("cfg.scopes", cfg.scopes), attribute.String("cfg.subject_token_type", string(cfg.subjectTokenType)), + attribute.String("cfg.body_format", bodyFormatString(cfg.bodyFormat)), attribute.String("correlation_id", correlationID), )