Skip to content

Commit e8dd037

Browse files
committed
feat(http_config): support JWT token auth as alternative to client secret (RFC 7523 3.1)
Signed-off-by: Jan-Otto Kröpke <[email protected]>
1 parent 8de85c2 commit e8dd037

File tree

2 files changed

+151
-29
lines changed

2 files changed

+151
-29
lines changed

config/http_config.go

Lines changed: 84 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ import (
3131
"sync"
3232
"time"
3333

34-
conntrack "github.com/mwitkow/go-conntrack"
34+
"github.com/mwitkow/go-conntrack"
3535
"golang.org/x/net/http/httpproxy"
3636
"golang.org/x/net/http2"
3737
"golang.org/x/oauth2"
3838
"golang.org/x/oauth2/clientcredentials"
39+
"golang.org/x/oauth2/jwt"
3940
"gopkg.in/yaml.v2"
4041
)
4142

@@ -241,8 +242,22 @@ type OAuth2 struct {
241242
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
242243
TokenURL string `yaml:"token_url" json:"token_url"`
243244
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
244-
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
245-
ProxyConfig `yaml:",inline"`
245+
246+
ClientCertificateKeyID string `yaml:"client_certificate_key_id" json:"client_certificate_key_id"`
247+
ClientCertificateKey Secret `yaml:"client_certificate_key" json:"client_certificate_key"`
248+
ClientCertificateKeyFile string `yaml:"client_certificate_key_file" json:"client_certificate_key_file"`
249+
// ClientCertificateKeyRef is the name of the secret within the secret manager to use as the client
250+
// secret.
251+
ClientCertificateKeyRef string `yaml:"client_certificate_key_ref" json:"client_certificate_key_ref"`
252+
// GrantType is the OAuth2 grant type to use. It can be one of
253+
// "client_credentials" or "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523).
254+
GrantType string `yaml:"grant_type" json:"grant_type"`
255+
// Claims is a map of claims to be added to the JWT token. Only used if
256+
// GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer".
257+
Claims map[string]interface{} `yaml:"claims,omitempty" json:"claims,omitempty"`
258+
259+
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
260+
ProxyConfig `yaml:",inline"`
246261
}
247262

248263
// UnmarshalYAML implements the yaml.Unmarshaler interface
@@ -408,8 +423,12 @@ func (c *HTTPClientConfig) Validate() error {
408423
if len(c.OAuth2.TokenURL) == 0 {
409424
return errors.New("oauth2 token_url must be configured")
410425
}
411-
if nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 1 {
412-
return errors.New("at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured")
426+
if nonZeroCount(
427+
len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0,
428+
len(c.OAuth2.ClientCertificateKey) > 0, len(c.OAuth2.ClientCertificateKeyFile) > 0, len(c.OAuth2.ClientCertificateKeyRef) > 0,
429+
) > 1 {
430+
return errors.New("at most one of oauth2 client_secret, client_secret_file, client_secret_ref, " +
431+
"client_certificate_key, client_certificate_key_file, client_certificate_key_ref must be configured")
413432
}
414433
}
415434
if err := c.ProxyConfig.Validate(); err != nil {
@@ -662,11 +681,24 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
662681
}
663682

664683
if cfg.OAuth2 != nil {
665-
clientSecret, err := toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef)
666-
if err != nil {
667-
return nil, fmt.Errorf("unable to use client secret: %w", err)
684+
var (
685+
clientCredential SecretReader
686+
err error
687+
)
688+
689+
if cfg.OAuth2.GrantType == "urn:ietf:params:oauth:grant-type:jwt-bearer" {
690+
clientCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientCertificateKey, cfg.OAuth2.ClientCertificateKeyFile, cfg.OAuth2.ClientCertificateKeyRef)
691+
if err != nil {
692+
return nil, fmt.Errorf("unable to use client certificate: %w", err)
693+
}
694+
} else {
695+
clientCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef)
696+
if err != nil {
697+
return nil, fmt.Errorf("unable to use client secret: %w", err)
698+
}
668699
}
669-
rt = NewOAuth2RoundTripper(clientSecret, cfg.OAuth2, rt, &opts)
700+
701+
rt = NewOAuth2RoundTripper(clientCredential, cfg.OAuth2, rt, &opts)
670702
}
671703

672704
if cfg.HTTPHeaders != nil {
@@ -885,27 +917,34 @@ type oauth2RoundTripper struct {
885917
lastSecret string
886918

887919
// Required for interaction with Oauth2 server.
888-
config *OAuth2
889-
clientSecret SecretReader
890-
opts *httpClientOptions
891-
client *http.Client
920+
config *OAuth2
921+
clientCredential SecretReader // SecretReader for client secret or client certificate key.
922+
opts *httpClientOptions
923+
client *http.Client
892924
}
893925

894-
func NewOAuth2RoundTripper(clientSecret SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper {
895-
if clientSecret == nil {
896-
clientSecret = NewInlineSecret("")
926+
// NewOAuth2RoundTripper returns a http.RoundTripper
927+
// that handles the OAuth2 authentication.
928+
// It uses the provided clientCredential to fetch the client secret or client certificate key.
929+
func NewOAuth2RoundTripper(clientCredential SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper {
930+
if clientCredential == nil {
931+
clientCredential = NewInlineSecret("")
897932
}
898933

899934
return &oauth2RoundTripper{
900935
config: config,
901936
// A correct tokenSource will be added later on.
902-
lastRT: &oauth2.Transport{Base: next},
903-
opts: opts,
904-
clientSecret: clientSecret,
937+
lastRT: &oauth2.Transport{Base: next},
938+
opts: opts,
939+
clientCredential: clientCredential,
905940
}
906941
}
907942

908-
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret string) (client *http.Client, source oauth2.TokenSource, err error) {
943+
type oauth2TokenSourceConfig interface {
944+
TokenSource(ctx context.Context) oauth2.TokenSource
945+
}
946+
947+
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential string) (client *http.Client, source oauth2.TokenSource, err error) {
909948
tlsConfig, err := NewTLSConfig(&rt.config.TLSConfig, WithSecretManager(rt.opts.secretManager))
910949
if err != nil {
911950
return nil, nil, err
@@ -943,13 +982,30 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret str
943982
t = NewUserAgentRoundTripper(ua, t)
944983
}
945984

946-
config := &clientcredentials.Config{
947-
ClientID: rt.config.ClientID,
948-
ClientSecret: secret,
949-
Scopes: rt.config.Scopes,
950-
TokenURL: rt.config.TokenURL,
951-
EndpointParams: mapToValues(rt.config.EndpointParams),
985+
var config oauth2TokenSourceConfig
986+
987+
if rt.config.GrantType == "urn:ietf:params:oauth:grant-type:jwt-bearer" {
988+
// RFC 7523 3.1 - JWT authorization grants
989+
// RFC 7523 3.2 - Client Authentication Processing is not implement upstream yet,
990+
// see https://github.com/golang/oauth2/pull/745
991+
992+
config = &jwt.Config{
993+
PrivateKey: []byte(clientCredential),
994+
PrivateKeyID: rt.config.ClientCertificateKeyID,
995+
Scopes: rt.config.Scopes,
996+
TokenURL: rt.config.TokenURL,
997+
PrivateClaims: rt.config.Claims,
998+
}
999+
} else {
1000+
config = &clientcredentials.Config{
1001+
ClientID: rt.config.ClientID,
1002+
ClientSecret: clientCredential,
1003+
Scopes: rt.config.Scopes,
1004+
TokenURL: rt.config.TokenURL,
1005+
EndpointParams: mapToValues(rt.config.EndpointParams),
1006+
}
9521007
}
1008+
9531009
client = &http.Client{Transport: t}
9541010
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
9551011
return client, config.TokenSource(ctx), nil
@@ -967,8 +1023,8 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
9671023
rt.mtx.RUnlock()
9681024

9691025
// Fetch the secret if it's our first run or always if the secret can change.
970-
if !rt.clientSecret.Immutable() || needsInit {
971-
newSecret, err := rt.clientSecret.Fetch(req.Context())
1026+
if !rt.clientCredential.Immutable() || needsInit {
1027+
newSecret, err := rt.clientCredential.Fetch(req.Context())
9721028
if err != nil {
9731029
return nil, fmt.Errorf("unable to read oauth2 client secret: %w", err)
9741030
}

config/http_config_test.go

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ var invalidHTTPClientConfigs = []struct {
110110
},
111111
{
112112
httpClientConfigFile: "testdata/http.conf.oauth2-secret-and-file-set.bad.yml",
113-
errMsg: "at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured",
113+
errMsg: "at most one of oauth2 client_secret, client_secret_file, client_secret_ref, client_certificate_key, client_certificate_key_file, client_certificate_key_ref must be configured",
114114
},
115115
{
116116
httpClientConfigFile: "testdata/http.conf.oauth2-no-client-id.bad.yaml",
@@ -505,6 +505,72 @@ func TestNewClientFromConfig(t *testing.T) {
505505
}
506506
},
507507
},
508+
{
509+
clientConfig: HTTPClientConfig{
510+
OAuth2: &OAuth2{
511+
ClientID: "ExpectedUsername",
512+
GrantType: "urn:ietf:params:oauth:grant-type:jwt-bearer",
513+
ClientCertificateKeyFile: ClientKeyNoPassPath,
514+
TLSConfig: TLSConfig{
515+
CAFile: TLSCAChainPath,
516+
CertFile: ClientCertificatePath,
517+
KeyFile: ClientKeyNoPassPath,
518+
ServerName: "",
519+
InsecureSkipVerify: false,
520+
},
521+
},
522+
TLSConfig: TLSConfig{
523+
CAFile: TLSCAChainPath,
524+
CertFile: ClientCertificatePath,
525+
KeyFile: ClientKeyNoPassPath,
526+
ServerName: "",
527+
InsecureSkipVerify: false,
528+
},
529+
},
530+
handler: func(w http.ResponseWriter, r *http.Request) {
531+
switch r.URL.Path {
532+
case "/token":
533+
if r.Method != http.MethodPost {
534+
w.WriteHeader(http.StatusBadRequest)
535+
536+
fmt.Fprintf(w, "Expected HTTP method %q, got %q", http.MethodPost, r.Method)
537+
538+
return
539+
}
540+
541+
if err := r.ParseForm(); err != nil {
542+
w.WriteHeader(http.StatusBadRequest)
543+
544+
fmt.Fprintf(w, "Unexpected error while parsing form: %s", err.Error())
545+
546+
return
547+
}
548+
549+
if r.PostFormValue("assertion") == "" {
550+
w.WriteHeader(http.StatusBadRequest)
551+
552+
fmt.Fprintf(w, "post body assertion missing")
553+
554+
return
555+
}
556+
557+
res, _ := json.Marshal(oauth2TestServerResponse{
558+
AccessToken: ExpectedAccessToken,
559+
TokenType: "Bearer",
560+
})
561+
w.Header().Add("Content-Type", "application/json")
562+
_, _ = w.Write(res)
563+
564+
default:
565+
authorization := r.Header.Get("Authorization")
566+
if authorization != "Bearer "+ExpectedAccessToken {
567+
fmt.Fprintf(w, "Expected Authorization header %q, got %q", "Bearer "+ExpectedAccessToken, authorization)
568+
} else {
569+
fmt.Fprint(w, ExpectedMessage)
570+
}
571+
}
572+
},
573+
},
508574
}
509575

510576
for _, validConfig := range newClientValidConfig {

0 commit comments

Comments
 (0)