Skip to content

Commit

Permalink
feat(auth): adds support for X509 workload identity federation (#10373)
Browse files Browse the repository at this point in the history
* feat: adds new provider type for X509 credentials and new client creation logic

* adds comments, minor fixes

* adds comment

* Add comment for linter

* Added default client logic so the external account credentials have visibility into whether the client should be overriden

* fix: added more comments

* Add cred source validation back in in the constructor

* Fix typo and consider default client from api lib changes

* fix unit test
  • Loading branch information
aeitzman authored Aug 7, 2024
1 parent 24616cc commit 5d07505
Show file tree
Hide file tree
Showing 11 changed files with 475 additions and 13 deletions.
1 change: 1 addition & 0 deletions auth/credentials/externalaccount/externalaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ func (o *Options) toInternalOpts() *iexacc.Options {
SubjectTokenProvider: toInternalSubjectTokenProvider(o.SubjectTokenProvider),
AwsSecurityCredentialsProvider: toInternalAwsSecurityCredentialsProvider(o.AwsSecurityCredentialsProvider),
Client: o.client(),
IsDefaultClient: o.Client == nil,
}
if o.CredentialSource != nil {
cs := o.CredentialSource
Expand Down
1 change: 1 addition & 0 deletions auth/credentials/filetypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ func handleExternalAccount(f *credsfile.ExternalAccountFile, opts *DetectOptions
Scopes: opts.scopes(),
WorkforcePoolUserProject: f.WorkforcePoolUserProject,
Client: opts.client(),
IsDefaultClient: opts.Client == nil,
}
if f.ServiceAccountImpersonation != nil {
externalOpts.ServiceAccountImpersonationLifetimeSeconds = f.ServiceAccountImpersonation.TokenLifetimeSeconds
Expand Down
44 changes: 42 additions & 2 deletions auth/credentials/internal/externalaccount/externalaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ type Options struct {
AwsSecurityCredentialsProvider AwsSecurityCredentialsProvider
// Client for token request.
Client *http.Client
// IsDefaultClient marks whether the client passed in is a default client that can be overriden.
// This is important for X509 credentials which should create a new client if the default was used
// but should respect a client explicitly passed in by the user.
IsDefaultClient bool
}

// SubjectTokenProvider can be used to supply a subject token to exchange for a
Expand Down Expand Up @@ -181,6 +185,26 @@ func (o *Options) validate() error {
return nil
}

// client returns the http client that should be used for the token exchange. If a non-default client
// is provided, then the client configured in the options will always be returned. If a default client
// is provided and the options are configured for X509 credentials, a new client will be created.
func (o *Options) client() (*http.Client, error) {
// If a client was provided and no override certificate config location was provided, use the provided client.
if o.CredentialSource == nil || o.CredentialSource.Certificate == nil || (!o.IsDefaultClient && o.CredentialSource.Certificate.CertificateConfigLocation == "") {
return o.Client, nil
}

// If a new client should be created, validate and use the certificate source to create a new mTLS client.
cert := o.CredentialSource.Certificate
if !cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation == "" {
return nil, errors.New("credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true")
}
if cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation != "" {
return nil, errors.New("credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true")
}
return createX509Client(cert.CertificateConfigLocation)
}

// resolveTokenURL sets the default STS token endpoint with the configured
// universe domain.
func (o *Options) resolveTokenURL() {
Expand All @@ -204,11 +228,18 @@ func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
if err != nil {
return nil, err
}

client, err := opts.client()
if err != nil {
return nil, err
}

tp := &tokenProvider{
client: opts.Client,
client: client,
opts: opts,
stp: stp,
}

if opts.ServiceAccountImpersonationURL == "" {
return auth.NewCachedTokenProvider(tp, nil), nil
}
Expand All @@ -218,7 +249,7 @@ func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
// needed for impersonation
tp.opts.Scopes = []string{"https://www.googleapis.com/auth/cloud-platform"}
imp, err := impersonate.NewTokenProvider(&impersonate.Options{
Client: opts.Client,
Client: client,
URL: opts.ServiceAccountImpersonationURL,
Scopes: scopes,
Tp: auth.NewCachedTokenProvider(tp, nil),
Expand Down Expand Up @@ -353,6 +384,15 @@ func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) {
execProvider.opts = o
execProvider.env = runtimeEnvironment{}
return execProvider, nil
} else if o.CredentialSource.Certificate != nil {
cert := o.CredentialSource.Certificate
if !cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation == "" {
return nil, errors.New("credentials: \"certificate\" object must either specify a certificate_config_location or use_default_certificate_config should be true")
}
if cert.UseDefaultCertificateConfig && cert.CertificateConfigLocation != "" {
return nil, errors.New("credentials: \"certificate\" object cannot specify both a certificate_config_location and use_default_certificate_config=true")
}
return &x509Provider{}, nil
}
return nil, errors.New("credentials: unable to parse credential source")
}
Expand Down
173 changes: 173 additions & 0 deletions auth/credentials/internal/externalaccount/externalaccount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,12 @@ import (
const (
textBaseCredPath = "testdata/3pi_cred.txt"
jsonBaseCredPath = "testdata/3pi_cred.json"
certConfigCredPath = "testdata/certificate_config_workload.json"
baseCredsRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
baseCredsResponseBody = `{"access_token":"Sample.Access.Token","issued_token_type":"urn:ietf:params:oauth:token-type:access_token","token_type":"Bearer","expires_in":3600,"scope":"https://www.googleapis.com/auth/cloud-platform"}`
workforcePoolRequestBodyWithClientID = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
workforcePoolRequestBodyWithoutClientID = "audience=%2F%2Fiam.googleapis.com%2Flocations%2Feu%2FworkforcePools%2Fpool-id%2Fproviders%2Fprovider-id&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&options=%7B%22userProject%22%3A%22myProject%22%7D&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=street123&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aid_token"
x509CredsRequestBody = "audience=32555940559.apps.googleusercontent.com&grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fdevstorage.full_control&subject_token=&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Amtls"
correctAT = "Sample.Access.Token"
expiry int64 = 234852
)
Expand Down Expand Up @@ -463,6 +465,177 @@ func TestOptionsValidate(t *testing.T) {
}
}

func TestClient(t *testing.T) {
goodCertConfigFileLocation := credsfile.CertificateConfig{
CertificateConfigLocation: "testdata/certificate_config_workload.json",
}
goodCertConfigEnvLocation := credsfile.CertificateConfig{
UseDefaultCertificateConfig: true,
}
badCertConfig := credsfile.CertificateConfig{
CertificateConfigLocation: "bad_file.json",
}
t.Setenv("GOOGLE_API_CERTIFICATE_CONFIG", "testdata/certificate_config_workload.json")

client := internal.CloneDefaultClient()

tests := []struct {
name string
o *Options
wantErr bool
wantClientChanged bool
}{
{
name: "isDefault true with no certificate config",
o: &Options{
Audience: "32555940559.apps.googleusercontent.com",
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: true,
CredentialSource: testBaseCredSource,
},
wantErr: false,
wantClientChanged: false,
},
{
name: "isDefault false with no certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: false,
CredentialSource: testBaseCredSource,
},
wantErr: false,
wantClientChanged: false,
},
{
name: "isDefault false with override certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: false,
CredentialSource: &credsfile.CredentialSource{Certificate: &goodCertConfigFileLocation},
},
wantErr: false,
wantClientChanged: true,
},
{
name: "isDefault true with override certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: true,
CredentialSource: &credsfile.CredentialSource{Certificate: &goodCertConfigFileLocation},
},
wantErr: false,
wantClientChanged: true,
},
{
name: "isDefault false with default certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: false,
CredentialSource: &credsfile.CredentialSource{Certificate: &goodCertConfigEnvLocation},
},
wantErr: false,
wantClientChanged: false,
},
{
name: "isDefault true with default certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: true,
CredentialSource: &credsfile.CredentialSource{Certificate: &goodCertConfigEnvLocation},
},
wantErr: false,
wantClientChanged: true,
},
{
name: "isDefault false with bad certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: false,
CredentialSource: &credsfile.CredentialSource{Certificate: &badCertConfig},
},
wantErr: true,
wantClientChanged: true,
},
{
name: "isDefault true with bad certificate config",
o: &Options{
SubjectTokenType: jwtTokenType,
TokenURL: "http://localhost:8080/v1/token",
TokenInfoURL: "http://localhost:8080/v1/tokeninfo",
ServiceAccountImpersonationURL: "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/service-gcs-admin@$PROJECT_ID.iam.gserviceaccount.com:generateAccessToken",
ClientSecret: "notsosecret",
ClientID: "rbrgnognrhongo3bi4gb9ghg9g",
Client: client,
IsDefaultClient: true,
CredentialSource: &credsfile.CredentialSource{Certificate: &badCertConfig},
},
wantErr: true,
wantClientChanged: true,
},
}
for _, tc := range tests {
actualClient, err := tc.o.client()
if err == nil && tc.wantErr {
t.Fatalf("o.validate() = nil, want error")
}
if err != nil && !tc.wantErr {
t.Fatalf(err.Error())
}

if tc.wantClientChanged {
if actualClient == client {
t.Fatalf("wanted client to change, but default was used")
}
} else {
if actualClient != client {
t.Fatalf("wanted default client, but client was changed. %s", tc.name)
}
}
}
}

func TestOptionsResolveTokenURL(t *testing.T) {
tests := []struct {
name string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"cert_configs": {
"workload": {
"cert_path": "testdata/workload_cert.pem",
"key_path": "testdata/workload_key.pem"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-----BEGIN CERTIFICATE-----
MIIDujCCAqICCQD+yrCYuiC8djANBgkqhkiG9w0BAQsFADCBnTELMAkGA1UEBhMC
VVMxEzARBgNVBAgMCldhc2hpbmd0b24xETAPBgNVBAcMCEtpcmtsYW5kMQ8wDQYD
VQQKDAZHb29nbGUxDjAMBgNVBAsMBUNsb3VkMRswGQYDVQQDDBJnb29nbGVhcGlz
dGVzdC5jb20xKDAmBgkqhkiG9w0BCQEWGWdvb2dsZWFwaXN0ZXN0QGdvb2dsZS5j
b20wIBcNMjAxMDIzMjEyNTU1WhgPMjEyMDA5MjkyMTI1NTVaMIGdMQswCQYDVQQG
EwJVUzETMBEGA1UECAwKV2FzaGluZ3RvbjERMA8GA1UEBwwIS2lya2xhbmQxDzAN
BgNVBAoMBkdvb2dsZTEOMAwGA1UECwwFQ2xvdWQxGzAZBgNVBAMMEmdvb2dsZWFw
aXN0ZXN0LmNvbTEoMCYGCSqGSIb3DQEJARYZZ29vZ2xlYXBpc3Rlc3RAZ29vZ2xl
LmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKnzFX97VP4XSQ8l
4/Z08eajnAiGpK+ZQTV9k7Qy2tpo5+iFFiL0JLGP9+GRILuDGQufYlPLDhLLho9V
YXIR9UOhhapmQJqUAUFhvZlBEixLxcfwa2LecNiJ6+8gvJCoRbrPIrz91crY+t59
aY/09vmsCbFDX8d8WWVnww4285dfKwE2IDinqZ1VuT4zYR66f4lL8qj6t5TXeGAW
Nkd6O3yuAVO8RLiXBRRABP5217mq0jNL+kJUormzhuKgvP+oxRsi56XHPGiq7l2e
54PS/cqa4atjqbhZI1xV27y0sVr0/CmBsfeM3TwLbCSjv7r0lCz64xtCJa8R45MA
22or9z8CAwEAATANBgkqhkiG9w0BAQsFAAOCAQEAnwLY9qBIQ2IYDLNLx16av8C6
9vca8gOzMpYZ4UKHDN+Qk2CidpmFamXWDXqmOLNZYlmEoGY5n8zg8rwYK+vauqwb
o94HzxLmQcQ4kmAI4xJnMqKZAbukRdWw2GCuvdVqG4Osngz4WBIHrAsl4btogdJy
ACU/YUA3K0tLjwe6wUYYF6eu5sb6zJkF4cfLpqECWtF9XG6nkJbo2GomHFuHm+6t
gOj7YiqU/cHCyU4FQF9/2jDLzFHxt2Bb30zi602YjuIZhYp35ktI66XwsE4kFmwo
iHCEG0fXMNN7OMFmNg2YVLhaHxrQNFxbzOQdfKg2gi2qzX4AiCo1tx5LCg6aGw==
-----END CERTIFICATE-----
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCp8xV/e1T+F0kP
JeP2dPHmo5wIhqSvmUE1fZO0MtraaOfohRYi9CSxj/fhkSC7gxkLn2JTyw4Sy4aP
VWFyEfVDoYWqZkCalAFBYb2ZQRIsS8XH8Gti3nDYievvILyQqEW6zyK8/dXK2Pre
fWmP9Pb5rAmxQ1/HfFllZ8MONvOXXysBNiA4p6mdVbk+M2Eeun+JS/Ko+reU13hg
FjZHejt8rgFTvES4lwUUQAT+dte5qtIzS/pCVKK5s4bioLz/qMUbIuelxzxoqu5d
nueD0v3KmuGrY6m4WSNcVdu8tLFa9PwpgbH3jN08C2wko7+69JQs+uMbQiWvEeOT
ANtqK/c/AgMBAAECggEAYjeE3hb1yJ7Gb0WzmDR/tI4rV9YQiRcl03cOjJ6zUnQ8
SmnXoD2+kwuj8y1/YD7kk436MnjwWjZbPqzWUylDuGE5sX/EqFEO5K1K+K3dhdII
rIMqXIo3Zz1WJ+2gbG2DVvHsnpKIIuIBIeISxsqIjUQ6mcJZMR2RQISV+roRTxIU
1Ga0xWrExcKL8FSjs8ih0DWU4vHoSYH4DFXB1/ViyLn+DEljnOlo8Q+7DG0uQQnX
ixfYMbXSJcZxFm1iwuZv8SESjqbTsogNny5Wi6H9Vp0JFasAPUjnc+QuD/U1HTDn
PCX3eBNMcxvVJDhu/7nnO7kcU1Cx0gJeN+1bklrAcQKBgQDURl0Ac8N94I82n4Lg
wjGLWj3AMxSEHNcZuomCvoYcLTmJdd2tOnunXhh1jANnx6q8P8aR5fiTthokIUdx
bOmWwFAbP6kMe0WFWQhXjX4mXLRmJ4mWayWCE7hstnDb3/Fr7LuJeg5L3OU4ss3b
j4UvhtuQ9Qh8piVhKwFkQh3tOQKBgQDM9NSkRDVW3Q37lMUdyn8B2FBF78e/9ck+
5bHOs52G2hXJ4tyLYNjBoLXPpMp9VWRTXxUaii+gHSa4DkHTkFwIg34hLgrCX7Gc
a0rldvkpX0xWSANfvO9bvavPgKnLSP8j3mjDiwqJuy3L5TBThIHDvPV9F/akpLne
bdcywa4ANwKBgHlvAzcGAniZJPRXjfRrwxH3/slbr0nggcDLMG0l9uxZhse3MKgv
g5t8PbvI7A3LcEWeqka+a1R84Tl3/DnL11kRDQJ5iYiFYIDnLNmBLQBfGigySAhP
pTZjd6ZhO/DcjGx0EdiUhWcqp8qmpxMKaGOG30ZulntQRKPwiSxEkoApAoGBAJ1o
h4ulawXMfnmyt3T62XJ0TKp5zoKqZSYuSNIEdr5j7goAdvuApNiI8jmISY/arlOt
mcqpSIyC9wKyyHGQ1G4hdxRKhS7lScZlTL9REWlp7HnzksvLklV2JWcXXNBovrMw
lGth9PT00eZfni72fKb1D+FEL0Qh0zJ2T6mGwHkfAoGAMOy8bbyCASCYG9MYzqaP
Lf+AKKNEYUvUGspyJUqu5ERudr5stmei6PrchxFiKjm5Qg7B/M1VnKsCtL9kk8Z9
lHgwU5mOATZvd9k/5oiuRxzXyrWqFoT/mivI2rZE+g5cLTLytCTnyLjHm5B/aTy8
1AmbAh5hvWYs+EMKZAlQ5GM=
-----END PRIVATE KEY-----
1 change: 1 addition & 0 deletions auth/credentials/internal/externalaccount/url_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const (
fileTypeJSON = "json"
urlProviderType = "url"
programmaticProviderType = "programmatic"
x509ProviderType = "x509"
)

type urlSubjectProvider struct {
Expand Down
Loading

0 comments on commit 5d07505

Please sign in to comment.