Skip to content

Commit

Permalink
feat(auth): port external account changes (#8697)
Browse files Browse the repository at this point in the history
  • Loading branch information
codyoss committed Oct 18, 2023
1 parent 74b1547 commit 5823db5
Show file tree
Hide file tree
Showing 25 changed files with 771 additions and 87 deletions.
58 changes: 58 additions & 0 deletions auth/detect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,64 @@ func TestDefaultCredentials_ExternalAccountKey(t *testing.T) {
t.Fatalf("got %q, want %q", tok.Type, want)
}
}
func TestDefaultCredentials_ExternalAccountAuthorizedUserKey(t *testing.T) {
b, err := os.ReadFile("../internal/testdata/exaccount_user.json")
if err != nil {
t.Fatal(err)
}
f, err := internaldetect.ParseExternalAccountAuthorizedUser(b)
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
if got, want := r.URL.Path, "/sts"; got != want {
t.Errorf("got %q, want %q", got, want)
}
r.ParseForm()
if got, want := r.Form.Get("refresh_token"), "refreshing"; got != want {
t.Errorf("got %q, want %q", got, want)
}
if got, want := r.Form.Get("grant_type"), "refresh_token"; got != want {
t.Errorf("got %q, want %q", got, want)
}

resp := &struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}{
AccessToken: "a_fake_token",
ExpiresIn: 60,
}
if err := json.NewEncoder(w).Encode(&resp); err != nil {
t.Error(err)
}
}))
f.TokenURL = ts.URL + "/sts"
b, err = json.Marshal(f)
if err != nil {
t.Fatal(err)
}

creds, err := DefaultCredentials(&Options{
CredentialsJSON: b,
Scopes: []string{"https://www.googleapis.com/auth/cloud-platform"},
UseSelfSignedJWT: true,
})
if err != nil {
t.Fatal(err)
}
tok, err := creds.Token(context.Background())
if err != nil {
t.Fatalf("creds.Token() = %v", err)
}
if want := "a_fake_token"; tok.Value != want {
t.Fatalf("got %q, want %q", tok.Value, want)
}
if want := internal.TokenTypeBearer; tok.Type != want {
t.Fatalf("got %q, want %q", tok.Type, want)
}
}

func TestDefaultCredentials_Fails(t *testing.T) {
t.Setenv("GOOGLE_APPLICATION_CREDENTIALS", "nothingToSeeHere")
Expand Down
2 changes: 2 additions & 0 deletions auth/detect/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@
// executable-sourced credentials), please check out:
// https://cloud.google.com/iam/docs/workforce-obtaining-short-lived-credentials#generate_a_configuration_file_for_non-interactive_sign-in
//
// # Security considerations
//
// Note that this library does not perform any validation on the token_url,
// token_info_url, or service_account_impersonation_url fields of the credential
// configuration. It is not recommended to use a credential configuration that
Expand Down
25 changes: 25 additions & 0 deletions auth/detect/filetypes.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"cloud.google.com/go/auth"
"cloud.google.com/go/auth/detect/internal/externalaccount"
"cloud.google.com/go/auth/detect/internal/externalaccountuser"
"cloud.google.com/go/auth/detect/internal/gdch"
"cloud.google.com/go/auth/detect/internal/impersonate"
"cloud.google.com/go/auth/internal/internaldetect"
Expand Down Expand Up @@ -66,6 +67,16 @@ func fileCredentials(b []byte, opts *Options) (*Credentials, error) {
}
quotaProjectID = f.QuotaProjectID
universeDomain = f.UniverseDomain
case internaldetect.ExternalAccountAuthorizedUserKey:
f, err := internaldetect.ParseExternalAccountAuthorizedUser(b)
if err != nil {
return nil, err
}
tp, err = handleExternalAccountAuthorizedUser(f, opts)
if err != nil {
return nil, err
}
quotaProjectID = f.QuotaProjectID
case internaldetect.ImpersonatedServiceAccountKey:
f, err := internaldetect.ParseImpersonatedServiceAccount(b)
if err != nil {
Expand Down Expand Up @@ -145,6 +156,20 @@ func handleExternalAccount(f *internaldetect.ExternalAccountFile, opts *Options)
return externalaccount.NewTokenProvider(externalOpts)
}

func handleExternalAccountAuthorizedUser(f *internaldetect.ExternalAccountAuthorizedUserFile, opts *Options) (auth.TokenProvider, error) {
externalOpts := &externalaccountuser.Options{
Audience: f.Audience,
RefreshToken: f.RefreshToken,
TokenURL: f.TokenURL,
TokenInfoURL: f.TokenInfoURL,
ClientID: f.ClientID,
ClientSecret: f.ClientSecret,
Scopes: opts.scopes(),
Client: opts.client(),
}
return externalaccountuser.NewTokenProvider(externalOpts)
}

func handleImpersonatedServiceAccount(f *internaldetect.ImpersonatedServiceAccountFile, opts *Options) (auth.TokenProvider, error) {
if f.ServiceAccountImpersonationURL == "" || f.CredSource == nil {
return nil, errors.New("missing 'source_credentials' field or 'service_account_impersonation_url' in credentials")
Expand Down
5 changes: 5 additions & 0 deletions auth/detect/internal/externalaccount/aws_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ const (

awsTimeFormatLong = "20060102T150405Z"
awsTimeFormatShort = "20060102"
awsProviderType = "aws"
)

type awsSubjectProvider struct {
Expand Down Expand Up @@ -168,6 +169,10 @@ func (sp *awsSubjectProvider) subjectToken(ctx context.Context) (string, error)
return url.QueryEscape(string(result)), nil
}

func (sp *awsSubjectProvider) providerType() string {
return awsProviderType
}

func (cs *awsSubjectProvider) getAWSSessionToken(ctx context.Context) (string, error) {
if cs.IMDSv2SessionTokenURL == "" {
return "", nil
Expand Down
4 changes: 4 additions & 0 deletions auth/detect/internal/externalaccount/aws_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -574,6 +574,10 @@ func TestAWSCredential_BasicRequest(t *testing.T) {
t.Fatalf("retrieveSubjectToken() failed: %v", err)
}

if got, want := base.providerType(), awsProviderType; got != want {
t.Fatalf("got %q, want %q", got, want)
}

want := getExpectedSubjectToken(
"https://sts.us-east-2.amazonaws.com?Action=GetCallerIdentity&Version=2011-06-15",
"us-east-2",
Expand Down
5 changes: 5 additions & 0 deletions auth/detect/internal/externalaccount/executable_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const (
executableSupportedMaxVersion = 1
executableDefaultTimeout = 30 * time.Second
executableSource = "response"
executableProviderType = "executable"
outputFileSource = "output file"

allowExecutablesEnvVar = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES"
Expand Down Expand Up @@ -175,6 +176,10 @@ func (cs *executableSubjectProvider) subjectToken(ctx context.Context) (string,
return cs.getTokenFromExecutableCommand(ctx)
}

func (cs *executableSubjectProvider) providerType() string {
return executableProviderType
}

func (cs *executableSubjectProvider) getTokenFromOutputFile() (token string, err error) {
if cs.OutputFile == "" {
// This ExecutableCredentialSource doesn't use an OutputFile.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,9 @@ func TestRetrieveExecutableSubjectTokenExecutableErrors(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
ecs.env = &tt.testEnvironment

if got, want := ecs.providerType(), executableProviderType; got != want {
t.Fatalf("got %q, want %q", got, want)
}
if _, err = ecs.subjectToken(context.Background()); err == nil {
t.Fatalf("got nil, want an error")
} else if tt.skipErrorEquals {
Expand Down
37 changes: 23 additions & 14 deletions auth/detect/internal/externalaccount/externalaccount.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,11 @@ import (

"cloud.google.com/go/auth"
"cloud.google.com/go/auth/detect/internal/impersonate"
"cloud.google.com/go/auth/detect/internal/stsexchange"
"cloud.google.com/go/auth/internal/internaldetect"
)

const (
stsGrantType = "urn:ietf:params:oauth:grant-type:token-exchange"
stsTokenType = "urn:ietf:params:oauth:token-type:access_token"

timeoutMinimum = 5 * time.Second
timeoutMaximum = 120 * time.Second
)
Expand Down Expand Up @@ -127,6 +125,7 @@ func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {

type subjectTokenProvider interface {
subjectToken(ctx context.Context) (string, error)
providerType() string
}

// tokenProvider is the provider that handles external credentials. It is used to retrieve Tokens.
Expand All @@ -142,17 +141,18 @@ func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) {
return nil, err
}

stsRequest := &stsTokenExchangeRequest{
GrantType: stsGrantType,
stsRequest := &stsexchange.TokenRequest{
GrantType: stsexchange.GrantType,
Audience: tp.opts.Audience,
Scope: tp.opts.Scopes,
RequestedTokenType: stsTokenType,
RequestedTokenType: stsexchange.TokenType,
SubjectToken: subjectToken,
SubjectTokenType: tp.opts.SubjectTokenType,
}
header := make(http.Header)
header.Set("Content-Type", "application/x-www-form-urlencoded")
clientAuth := clientAuthentication{
header.Add("x-goog-api-client", getGoogHeaderValue(tp.opts, tp.stp))
clientAuth := stsexchange.ClientAuthentication{
AuthStyle: auth.StyleInHeader,
ClientID: tp.opts.ClientID,
ClientSecret: tp.opts.ClientSecret,
Expand All @@ -165,13 +165,13 @@ func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) {
"userProject": tp.opts.WorkforcePoolUserProject,
}
}
stsResp, err := exchangeToken(ctx, &exchangeOptions{
client: tp.client,
endpoint: tp.opts.TokenURL,
request: stsRequest,
authentication: clientAuth,
headers: header,
extraOpts: options,
stsResp, err := stsexchange.ExchangeToken(ctx, &stsexchange.Options{
Client: tp.client,
Endpoint: tp.opts.TokenURL,
Request: stsRequest,
Authentication: clientAuth,
Headers: header,
ExtraOpts: options,
})
if err != nil {
return nil, err
Expand Down Expand Up @@ -240,3 +240,12 @@ func newSubjectTokenProvider(o *Options) (subjectTokenProvider, error) {
}
return nil, errors.New("detect: unable to parse credential source")
}

func getGoogHeaderValue(conf *Options, p subjectTokenProvider) string {
return fmt.Sprintf("gl-go/%s auth/%s google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t",
goVersion(),
"unknown",
p.providerType(),
conf.ServiceAccountImpersonationURL != "",
conf.ServiceAccountImpersonationLifetimeSeconds != 0)
}
13 changes: 13 additions & 0 deletions auth/detect/internal/externalaccount/externalaccount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ package externalaccount

import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
Expand Down Expand Up @@ -72,6 +73,7 @@ func TestToken(t *testing.T) {
contentType: "application/x-www-form-urlencoded",
body: baseCredsRequestBody,
response: baseCredsResponseBody,
metricsHeader: expectedMetricsHeader("file", false, false),
}

tok, err := run(t, opts, server)
Expand All @@ -98,6 +100,7 @@ func TestWorkforcePoolTokenWithClientID(t *testing.T) {
contentType: "application/x-www-form-urlencoded",
body: workforcePoolRequestBodyWithClientID,
response: baseCredsResponseBody,
metricsHeader: expectedMetricsHeader("file", false, false),
}

tok, err := run(t, &opts, &server)
Expand All @@ -123,6 +126,7 @@ func TestWorkforcePoolTokenWithoutClientID(t *testing.T) {
contentType: "application/x-www-form-urlencoded",
body: workforcePoolRequestBodyWithoutClientID,
response: baseCredsResponseBody,
metricsHeader: expectedMetricsHeader("file", false, false),
}

tok, err := run(t, &opts, &server)
Expand Down Expand Up @@ -196,6 +200,7 @@ type testExchangeTokenServer struct {
contentType string
body string
response string
metricsHeader string
}

func run(t *testing.T, opts *Options, tets *testExchangeTokenServer) (*auth.Token, error) {
Expand All @@ -211,6 +216,10 @@ func run(t *testing.T, opts *Options, tets *testExchangeTokenServer) (*auth.Toke
if got, want := headerContentType, tets.contentType; got != want {
t.Errorf("got %v, want %v", got, want)
}
headerMetrics := r.Header.Get("x-goog-api-client")
if got, want := headerMetrics, tets.metricsHeader; got != want {
t.Errorf("got %v but want %v", got, want)
}
body, err := io.ReadAll(r.Body)
if err != nil {
t.Fatalf("Failed reading request body: %s.", err)
Expand Down Expand Up @@ -266,3 +275,7 @@ func cloneTestOpts() *Options {
Client: internal.CloneDefaultClient(),
}
}

func expectedMetricsHeader(source string, saImpersonation bool, configLifetime bool) string {
return fmt.Sprintf("gl-go/%s auth/unknown google-byoid-sdk source/%s sa-impersonation/%t config-lifetime/%t", goVersion(), source, saImpersonation, configLifetime)
}
8 changes: 8 additions & 0 deletions auth/detect/internal/externalaccount/file_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import (
"cloud.google.com/go/auth/internal/internaldetect"
)

const (
fileProviderType = "file"
)

type fileSubjectProvider struct {
File string
Format internaldetect.Format
Expand Down Expand Up @@ -64,3 +68,7 @@ func (sp *fileSubjectProvider) subjectToken(context.Context) (string, error) {
return "", errors.New("detect: invalid credential_source file format type: " + sp.Format.Type)
}
}

func (sp *fileSubjectProvider) providerType() string {
return fileProviderType
}
4 changes: 3 additions & 1 deletion auth/detect/internal/externalaccount/file_provider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ func TestRetrieveFileSubjectToken(t *testing.T) {
} else if test.want != out {
t.Errorf("got %v, want %v", out, test.want)
}

if got, want := base.providerType(), fileProviderType; got != want {
t.Fatalf("got %q, want %q", got, want)
}
})
}
}
13 changes: 8 additions & 5 deletions auth/detect/internal/externalaccount/impersonate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,10 @@ var (

func TestImpersonation(t *testing.T) {
var impersonationTests = []struct {
name string
opts *Options
wantBody string
name string
opts *Options
wantBody string
metricsHeader string
}{
{
name: "Base Impersonation",
Expand All @@ -46,7 +47,8 @@ func TestImpersonation(t *testing.T) {
CredentialSource: testBaseCredSource,
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
},
wantBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
wantBody: "{\"lifetime\":\"3600s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
metricsHeader: expectedMetricsHeader("file", true, false),
},
{
name: "With TokenLifetime Set",
Expand All @@ -60,7 +62,8 @@ func TestImpersonation(t *testing.T) {
Scopes: []string{"https://www.googleapis.com/auth/devstorage.full_control"},
ServiceAccountImpersonationLifetimeSeconds: 10000,
},
wantBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
wantBody: "{\"lifetime\":\"10000s\",\"scope\":[\"https://www.googleapis.com/auth/devstorage.full_control\"]}",
metricsHeader: expectedMetricsHeader("file", true, false),
},
}
for _, tt := range impersonationTests {
Expand Down

0 comments on commit 5823db5

Please sign in to comment.