Skip to content

Commit f4c0aea

Browse files
authored
Support JWT Profile for Authorization Grant (RFC 7523 3.1) (#862)
1 parent 594f4d4 commit f4c0aea

File tree

6 files changed

+437
-44
lines changed

6 files changed

+437
-44
lines changed

config/http_config.go

Lines changed: 124 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,24 @@ import (
2727
"net/url"
2828
"os"
2929
"path/filepath"
30+
"slices"
3031
"strings"
3132
"sync"
3233
"time"
3334

34-
conntrack "github.com/mwitkow/go-conntrack"
35+
"github.com/golang-jwt/jwt/v5"
36+
"github.com/mwitkow/go-conntrack"
3537
"go.yaml.in/yaml/v2"
3638
"golang.org/x/net/http/httpproxy"
3739
"golang.org/x/net/http2"
3840
"golang.org/x/oauth2"
3941
"golang.org/x/oauth2/clientcredentials"
4042
)
4143

44+
const (
45+
grantTypeJWTBearer = "urn:ietf:params:oauth:grant-type:jwt-bearer"
46+
)
47+
4248
var (
4349
// DefaultHTTPClientConfig is the default HTTP client configuration.
4450
DefaultHTTPClientConfig = HTTPClientConfig{
@@ -237,12 +243,38 @@ type OAuth2 struct {
237243
ClientSecretFile string `yaml:"client_secret_file" json:"client_secret_file"`
238244
// ClientSecretRef is the name of the secret within the secret manager to use as the client
239245
// secret.
240-
ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"`
241-
Scopes []string `yaml:"scopes,omitempty" json:"scopes,omitempty"`
242-
TokenURL string `yaml:"token_url" json:"token_url"`
243-
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
244-
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
245-
ProxyConfig `yaml:",inline"`
246+
ClientSecretRef string `yaml:"client_secret_ref" json:"client_secret_ref"`
247+
ClientCertificateKeyID string `yaml:"client_certificate_key_id" json:"client_certificate_key_id"`
248+
ClientCertificateKey Secret `yaml:"client_certificate_key" json:"client_certificate_key"`
249+
ClientCertificateKeyFile string `yaml:"client_certificate_key_file" json:"client_certificate_key_file"`
250+
// ClientCertificateKeyRef is the name of the secret within the secret manager to use as the client
251+
// secret.
252+
ClientCertificateKeyRef string `yaml:"client_certificate_key_ref" json:"client_certificate_key_ref"`
253+
// GrantType is the OAuth2 grant type to use. It can be one of
254+
// "client_credentials" or "urn:ietf:params:oauth:grant-type:jwt-bearer" (RFC 7523).
255+
// Default value is "client_credentials"
256+
GrantType string `yaml:"grant_type" json:"grant_type"`
257+
// SignatureAlgorithm is the RSA algorithm used to sign JWT token. Only used if
258+
// GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer".
259+
// Default value is RS256 and valid values RS256, RS384, RS512
260+
SignatureAlgorithm string `yaml:"signature_algorithm,omitempty" json:"signature_algorithm,omitempty"`
261+
// Iss is the OAuth client identifier used when communicating with
262+
// the configured OAuth provider. Default value is client_id. Only used if
263+
// GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer".
264+
Iss string `yaml:"iss,omitempty" json:"iss,omitempty"`
265+
// Audience optionally specifies the intended audience of the
266+
// request. If empty, the value of TokenURL is used as the
267+
// intended audience. Only used if
268+
// GrantType is set to "urn:ietf:params:oauth:grant-type:jwt-bearer".
269+
Audience string `yaml:"audience,omitempty" json:"audience,omitempty"`
270+
// Claims is a map of claims to be added to the JWT token. Only used if
271+
// 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"`
274+
TokenURL string `yaml:"token_url" json:"token_url"`
275+
EndpointParams map[string]string `yaml:"endpoint_params,omitempty" json:"endpoint_params,omitempty"`
276+
TLSConfig TLSConfig `yaml:"tls_config,omitempty"`
277+
ProxyConfig `yaml:",inline"`
246278
}
247279

248280
// UnmarshalYAML implements the yaml.Unmarshaler interface.
@@ -408,8 +440,15 @@ func (c *HTTPClientConfig) Validate() error {
408440
if len(c.OAuth2.TokenURL) == 0 {
409441
return errors.New("oauth2 token_url must be configured")
410442
}
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")
443+
if c.OAuth2.GrantType == grantTypeJWTBearer {
444+
if nonZeroCount(len(c.OAuth2.ClientCertificateKey) > 0, len(c.OAuth2.ClientCertificateKeyFile) > 0, len(c.OAuth2.ClientCertificateKeyRef) > 0) > 1 {
445+
return errors.New("at most one of oauth2 client_certificate_key, client_certificate_key_file & client_certificate_key_ref must be configured using grant-type=urn:ietf:params:oauth:grant-type:jwt-bearer")
446+
}
447+
if c.OAuth2.SignatureAlgorithm != "" && !slices.Contains(validSignatureAlgorithm, c.OAuth2.SignatureAlgorithm) {
448+
return errors.New("valid signature algorithms are RS256, RS384 and RS512")
449+
}
450+
} else if nonZeroCount(len(c.OAuth2.ClientSecret) > 0, len(c.OAuth2.ClientSecretFile) > 0, len(c.OAuth2.ClientSecretRef) > 0) > 1 {
451+
return errors.New("at most one of oauth2 client_secret, client_secret_file & client_secret_ref must be configured using grant-type=client_credentials")
413452
}
414453
}
415454
if err := c.ProxyConfig.Validate(); err != nil {
@@ -668,11 +707,23 @@ func NewRoundTripperFromConfigWithContext(ctx context.Context, cfg HTTPClientCon
668707
}
669708

670709
if cfg.OAuth2 != nil {
671-
clientSecret, err := toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef)
672-
if err != nil {
673-
return nil, fmt.Errorf("unable to use client secret: %w", err)
710+
var (
711+
oauthCredential SecretReader
712+
err error
713+
)
714+
715+
if cfg.OAuth2.GrantType == grantTypeJWTBearer {
716+
oauthCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientCertificateKey, cfg.OAuth2.ClientCertificateKeyFile, cfg.OAuth2.ClientCertificateKeyRef)
717+
if err != nil {
718+
return nil, fmt.Errorf("unable to use client certificate: %w", err)
719+
}
720+
} else {
721+
oauthCredential, err = toSecret(opts.secretManager, cfg.OAuth2.ClientSecret, cfg.OAuth2.ClientSecretFile, cfg.OAuth2.ClientSecretRef)
722+
if err != nil {
723+
return nil, fmt.Errorf("unable to use client secret: %w", err)
724+
}
674725
}
675-
rt = NewOAuth2RoundTripper(clientSecret, cfg.OAuth2, rt, &opts)
726+
rt = NewOAuth2RoundTripper(oauthCredential, cfg.OAuth2, rt, &opts)
676727
}
677728

678729
if cfg.HTTPHeaders != nil {
@@ -891,27 +942,31 @@ type oauth2RoundTripper struct {
891942
lastSecret string
892943

893944
// Required for interaction with Oauth2 server.
894-
config *OAuth2
895-
clientSecret SecretReader
896-
opts *httpClientOptions
897-
client *http.Client
945+
config *OAuth2
946+
oauthCredential SecretReader
947+
opts *httpClientOptions
948+
client *http.Client
898949
}
899950

900-
func NewOAuth2RoundTripper(clientSecret SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper {
901-
if clientSecret == nil {
902-
clientSecret = NewInlineSecret("")
951+
func NewOAuth2RoundTripper(oauthCredential SecretReader, config *OAuth2, next http.RoundTripper, opts *httpClientOptions) http.RoundTripper {
952+
if oauthCredential == nil {
953+
oauthCredential = NewInlineSecret("")
903954
}
904955

905956
return &oauth2RoundTripper{
906957
config: config,
907958
// A correct tokenSource will be added later on.
908-
lastRT: &oauth2.Transport{Base: next},
909-
opts: opts,
910-
clientSecret: clientSecret,
959+
lastRT: &oauth2.Transport{Base: next},
960+
opts: opts,
961+
oauthCredential: oauthCredential,
911962
}
912963
}
913964

914-
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret string) (client *http.Client, source oauth2.TokenSource, err error) {
965+
type oauth2TokenSourceConfig interface {
966+
TokenSource(ctx context.Context) oauth2.TokenSource
967+
}
968+
969+
func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, clientCredential string) (client *http.Client, source oauth2.TokenSource, err error) {
915970
tlsConfig, err := NewTLSConfig(&rt.config.TLSConfig, WithSecretManager(rt.opts.secretManager))
916971
if err != nil {
917972
return nil, nil, err
@@ -949,12 +1004,49 @@ func (rt *oauth2RoundTripper) newOauth2TokenSource(req *http.Request, secret str
9491004
t = NewUserAgentRoundTripper(ua, t)
9501005
}
9511006

952-
config := &clientcredentials.Config{
953-
ClientID: rt.config.ClientID,
954-
ClientSecret: secret,
955-
Scopes: rt.config.Scopes,
956-
TokenURL: rt.config.TokenURL,
957-
EndpointParams: mapToValues(rt.config.EndpointParams),
1007+
var config oauth2TokenSourceConfig
1008+
1009+
if rt.config.GrantType == grantTypeJWTBearer {
1010+
// RFC 7523 3.1 - JWT authorization grants
1011+
// RFC 7523 3.2 - Client Authentication Processing is not implement upstream yet,
1012+
// see https://github.com/golang/oauth2/pull/745
1013+
1014+
var sig *jwt.SigningMethodRSA
1015+
switch rt.config.SignatureAlgorithm {
1016+
case jwt.SigningMethodRS256.Name:
1017+
sig = jwt.SigningMethodRS256
1018+
case jwt.SigningMethodRS384.Name:
1019+
sig = jwt.SigningMethodRS384
1020+
case jwt.SigningMethodRS512.Name:
1021+
sig = jwt.SigningMethodRS512
1022+
default:
1023+
sig = jwt.SigningMethodRS256
1024+
}
1025+
1026+
iss := rt.config.Iss
1027+
if iss == "" {
1028+
iss = rt.config.ClientID
1029+
}
1030+
config = &JwtGrantTypeConfig{
1031+
PrivateKey: []byte(clientCredential),
1032+
PrivateKeyID: rt.config.ClientCertificateKeyID,
1033+
Scopes: rt.config.Scopes,
1034+
TokenURL: rt.config.TokenURL,
1035+
SigningAlgorithm: sig,
1036+
Iss: iss,
1037+
Subject: rt.config.ClientID,
1038+
Audience: rt.config.Audience,
1039+
PrivateClaims: rt.config.Claims,
1040+
EndpointParams: mapToValues(rt.config.EndpointParams),
1041+
}
1042+
} else {
1043+
config = &clientcredentials.Config{
1044+
ClientID: rt.config.ClientID,
1045+
ClientSecret: clientCredential,
1046+
Scopes: rt.config.Scopes,
1047+
TokenURL: rt.config.TokenURL,
1048+
EndpointParams: mapToValues(rt.config.EndpointParams),
1049+
}
9581050
}
9591051
client = &http.Client{Transport: t}
9601052
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
@@ -973,8 +1065,8 @@ func (rt *oauth2RoundTripper) RoundTrip(req *http.Request) (*http.Response, erro
9731065
rt.mtx.RUnlock()
9741066

9751067
// Fetch the secret if it's our first run or always if the secret can change.
976-
if !rt.clientSecret.Immutable() || needsInit {
977-
newSecret, err := rt.clientSecret.Fetch(req.Context())
1068+
if !rt.oauthCredential.Immutable() || needsInit {
1069+
newSecret, err := rt.oauthCredential.Fetch(req.Context())
9781070
if err != nil {
9791071
return nil, fmt.Errorf("unable to read oauth2 client secret: %w", err)
9801072
}

0 commit comments

Comments
 (0)