Skip to content

Commit 04bfba4

Browse files
author
Ali
committed
config: add client_assertion / client_assertion_file OAuth2 fields
Some environments generate client assertions externally — via a KMS, sidecar, or dedicated job — and inject the pre-signed JWT as a file or env var. Until now there was no way to hand that token to the oauth2 transport; callers had to resort to workarounds. Add two new OAuth2 fields (RFC 7521 §4.2 / RFC 7523 §2.2): client_assertion: <inline pre-signed JWT> client_assertion_file: <path; re-read on every token refresh> When either is set the transport: - reads the assertion via the existing SecretReader path (inline or file, with live rotation for file-based assertions) - sends it to the token endpoint as client_assertion with the standard client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer - omits client_secret (the assertion authenticates the client) Validation rejects combining client_assertion* with client_secret* or client_certificate_key* and rejects supplying both fields at once. Closes #869 Signed-off-by: Ali <alliasgher123@gmail.com>
1 parent 9a26ab2 commit 04bfba4

2 files changed

Lines changed: 118 additions & 2 deletions

File tree

config/http_config.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -269,8 +269,17 @@ type OAuth2 struct {
269269
Audience string `yaml:"audience,omitempty" json:"audience,omitempty"`
270270
// Claims is a map of claims to be added to the JWT token. Only used if
271271
// GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer".
272-
Claims map[string]interface{} `yaml:"claims,omitempty" json:"claims,omitempty"`
273-
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
272+
Claims map[string]interface{} `yaml:"claims,omitempty" json:"claims,omitempty"`
273+
// ClientAssertion is a pre-signed JWT sent as the client_assertion parameter
274+
// (RFC 7521 §4.2 / RFC 7523 §2.2). Use this when the assertion is generated
275+
// externally (e.g. by a sidecar, KMS, or separate job). Mutually exclusive
276+
// with client_secret* and client_certificate_key*.
277+
ClientAssertion Secret `yaml:"client_assertion,omitempty" json:"client_assertion,omitempty"`
278+
// ClientAssertionFile is a path to a file whose contents are used as the
279+
// client_assertion. The file is re-read on every token refresh so that
280+
// rotated assertions are picked up automatically.
281+
ClientAssertionFile string `yaml:"client_assertion_file,omitempty" json:"client_assertion_file,omitempty"`
282+
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
274283
TokenURL string `yaml:"token_url,omitempty" json:"token_url,omitempty"`
275284
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
276285
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
@@ -450,6 +459,16 @@ func (c *HTTPClientConfig) Validate() error {
450459
} else if nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 1 {
451460
return errors.New("at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured using grant-type=client_credentials")
452461
}
462+
if hasAssertion := nonZeroCount(len(c.OAuth2.ClientAssertion) > 0, len(c.OAuth2.ClientAssertionFile) > 0) > 0; hasAssertion {
463+
if nonZeroCount(len(c.OAuth2.ClientAssertion) > 0, len(c.OAuth2.ClientAssertionFile) > 0) > 1 {
464+
return errors.New("at most one of oauth2 client_assertion and client_assertion_file must be configured")
465+
}
466+
hasSecret := nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 0
467+
hasCertKey := nonZeroCount(len(c.OAuth2.ClientCertificateKey) > 0, len(c.OAuth2.ClientCertificateKeyFile) > 0, len(c.OAuth2.ClientCertificateKeyRef) > 0) > 0
468+
if hasSecret || hasCertKey {
469+
return errors.New("oauth2 client_assertion cannot be combined with client_secret or client_certificate_key")
470+
}
471+
}
453472
}
454473
if err := c.ProxyConfig.Validate(); err != nil {
455474
return err
@@ -711,6 +730,12 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
711730
if err != nil {
712731
return nil, fmt.Errorf("unable to use client certificate: %w", err)
713732
}
733+
} else if len(cfg.OAuth2.ClientAssertion) > 0 || len(cfg.OAuth2.ClientAssertionFile) > 0 {
734+
// Pre-signed JWT assertion (RFC 7521 §4.2 / RFC 7523 §2.2).
735+
oauthCredential, err = toSecret(nil, cfg.OAuth2.ClientAssertion, cfg.OAuth2.ClientAssertionFile, "")
736+
if err != nil {
737+
return nil, fmt.Errorf("unable to use client assertion: %w", err)
738+
}
714739
} else {
715740
oauthCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef)
716741
if err != nil {
@@ -1033,6 +1058,19 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCred
10331058
PrivateClaims: rt.config.Claims,
10341059
EndpointParams: mapToValues(rt.config.EndpointParams),
10351060
}
1061+
} else if len(rt.config.ClientAssertion) > 0 || len(rt.config.ClientAssertionFile) > 0 {
1062+
// RFC 7521 §4.2 / RFC 7523 §2.2 — client_assertion authentication.
1063+
// The pre-signed JWT is sent as client_assertion together with the
1064+
// appropriate client_assertion_type; no client_secret is included.
1065+
params := mapToValues(rt.config.EndpointParams)
1066+
params.Set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
1067+
params.Set("client_assertion", clientCredential)
1068+
config = &clientcredentials.Config{
1069+
ClientID: rt.config.ClientID,
1070+
Scopes: rt.config.Scopes,
1071+
TokenURL: rt.config.TokenURL,
1072+
EndpointParams: params,
1073+
}
10361074
} else {
10371075
config = &clientcredentials.Config{
10381076
ClientID: rt.config.ClientID,

config/http_config_test.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2321,3 +2321,81 @@ func TestMultipleHeaders(t *testing.T) {
23212321
_, err = client.Get(ts.URL)
23222322
require.NoErrorf(t, err, "can't fetch URL: %v", err)
23232323
}
2324+
2325+
func TestOAuth2WithClientAssertion(t *testing.T) {
2326+
const preSignedJWT = "header.payload.signature"
2327+
2328+
var (
2329+
gotAssertionType string
2330+
gotAssertion string
2331+
)
2332+
tokenTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2333+
require.NoError(t, r.ParseForm())
2334+
gotAssertionType = r.FormValue("client_assertion_type")
2335+
gotAssertion = r.FormValue("client_assertion")
2336+
res, _ := json.Marshal(oauth2TestServerResponse{AccessToken: "tok", TokenType: "Bearer"})
2337+
w.Header().Set("Content-Type", "application/json")
2338+
_, _ = w.Write(res)
2339+
}))
2340+
defer tokenTS.Close()
2341+
appTS := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2342+
require.Equal(t, "Bearer tok", r.Header.Get("Authorization"))
2343+
fmt.Fprintln(w, "ok")
2344+
}))
2345+
defer appTS.Close()
2346+
2347+
cfg := &HTTPClientConfig{
2348+
OAuth2: &OAuth2{
2349+
ClientID: "my-client",
2350+
ClientAssertion: Secret(preSignedJWT),
2351+
TokenURL: tokenTS.URL,
2352+
},
2353+
}
2354+
client, err := NewClientFromConfig(*cfg, "test")
2355+
require.NoError(t, err)
2356+
2357+
_, err = client.Get(appTS.URL)
2358+
require.NoError(t, err)
2359+
2360+
require.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", gotAssertionType)
2361+
require.Equal(t, preSignedJWT, gotAssertion)
2362+
}
2363+
2364+
func TestOAuth2ClientAssertionValidation(t *testing.T) {
2365+
tests := []struct {
2366+
name string
2367+
cfg OAuth2
2368+
wantErr string
2369+
}{
2370+
{
2371+
name: "both client_assertion and client_assertion_file",
2372+
cfg: OAuth2{ClientID: "x", TokenURL: "http://t", ClientAssertion: "jwt", ClientAssertionFile: "/f"},
2373+
wantErr: "at most one of oauth2 client_assertion and client_assertion_file must be configured",
2374+
},
2375+
{
2376+
name: "client_assertion with client_secret",
2377+
cfg: OAuth2{ClientID: "x", TokenURL: "http://t", ClientAssertion: "jwt", ClientSecret: "s"},
2378+
wantErr: "oauth2 client_assertion cannot be combined with client_secret or client_certificate_key",
2379+
},
2380+
{
2381+
name: "client_assertion with client_certificate_key",
2382+
cfg: OAuth2{ClientID: "x", TokenURL: "http://t", ClientAssertion: "jwt", ClientCertificateKey: "k"},
2383+
wantErr: "oauth2 client_assertion cannot be combined with client_secret or client_certificate_key",
2384+
},
2385+
{
2386+
name: "client_assertion_file alone is valid",
2387+
cfg: OAuth2{ClientID: "x", TokenURL: "http://t", ClientAssertionFile: "/path/to/jwt"},
2388+
},
2389+
}
2390+
for _, tc := range tests {
2391+
t.Run(tc.name, func(t *testing.T) {
2392+
cfg := tc.cfg
2393+
err := (&HTTPClientConfig{OAuth2: &cfg}).Validate()
2394+
if tc.wantErr != "" {
2395+
require.ErrorContains(t, err, tc.wantErr)
2396+
} else {
2397+
require.NoError(t, err)
2398+
}
2399+
})
2400+
}
2401+
}

0 commit comments

Comments
 (0)