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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 2 additions & 2 deletions docs/rfc8693.md
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
4 changes: 3 additions & 1 deletion pkg/rfc8693/openai.go
Original file line number Diff line number Diff line change
Expand Up @@ -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...)
Expand Down
9 changes: 9 additions & 0 deletions pkg/rfc8693/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 33 additions & 5 deletions pkg/rfc8693/rfc8693.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"io"
"maps"
"net/http"
"net/url"
"strings"
"time"

Expand Down Expand Up @@ -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 {
Expand All @@ -78,6 +89,7 @@ type credentialsConfig struct {
audience string
scopes []string
additionalFields map[string]string
bodyFormat BodyFormat
Comment thread
bonifaido marked this conversation as resolved.
Resolved
httpClient *http.Client

tracerProvider trace.TracerProvider
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 52 additions & 24 deletions pkg/rfc8693/rfc8693_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
})
Expand All @@ -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()

Expand All @@ -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)
})
Expand All @@ -171,18 +191,16 @@ 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)
})

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", "")
Expand All @@ -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)
})
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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))
}
}
12 changes: 11 additions & 1 deletion pkg/rfc8693/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)

Expand Down
Loading