diff --git a/auth/detect/detect_test.go b/auth/detect/detect_test.go index 7fe98eeb2052..57983331b335 100644 --- a/auth/detect/detect_test.go +++ b/auth/detect/detect_test.go @@ -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") diff --git a/auth/detect/doc.go b/auth/detect/doc.go index 60ac56bb9556..027a59fb6aa6 100644 --- a/auth/detect/doc.go +++ b/auth/detect/doc.go @@ -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 diff --git a/auth/detect/filetypes.go b/auth/detect/filetypes.go index f0fde9e8fb5e..3d822d740a4e 100644 --- a/auth/detect/filetypes.go +++ b/auth/detect/filetypes.go @@ -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" @@ -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 { @@ -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") diff --git a/auth/detect/internal/externalaccount/aws_provider.go b/auth/detect/internal/externalaccount/aws_provider.go index 9b16a8d46c93..e8cd1d017f60 100644 --- a/auth/detect/internal/externalaccount/aws_provider.go +++ b/auth/detect/internal/externalaccount/aws_provider.go @@ -69,6 +69,7 @@ const ( awsTimeFormatLong = "20060102T150405Z" awsTimeFormatShort = "20060102" + awsProviderType = "aws" ) type awsSubjectProvider struct { @@ -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 diff --git a/auth/detect/internal/externalaccount/aws_provider_test.go b/auth/detect/internal/externalaccount/aws_provider_test.go index 4416c43ada0f..f181f42bad6c 100644 --- a/auth/detect/internal/externalaccount/aws_provider_test.go +++ b/auth/detect/internal/externalaccount/aws_provider_test.go @@ -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", diff --git a/auth/detect/internal/externalaccount/executable_provider.go b/auth/detect/internal/externalaccount/executable_provider.go index 4fd858b90413..db449f2b7dc3 100644 --- a/auth/detect/internal/externalaccount/executable_provider.go +++ b/auth/detect/internal/externalaccount/executable_provider.go @@ -34,6 +34,7 @@ const ( executableSupportedMaxVersion = 1 executableDefaultTimeout = 30 * time.Second executableSource = "response" + executableProviderType = "executable" outputFileSource = "output file" allowExecutablesEnvVar = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" @@ -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. diff --git a/auth/detect/internal/externalaccount/executable_provider_test.go b/auth/detect/internal/externalaccount/executable_provider_test.go index ce288090556b..d864600a3cb8 100644 --- a/auth/detect/internal/externalaccount/executable_provider_test.go +++ b/auth/detect/internal/externalaccount/executable_provider_test.go @@ -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 { diff --git a/auth/detect/internal/externalaccount/externalaccount.go b/auth/detect/internal/externalaccount/externalaccount.go index 2303fe097eda..9e97f05e63fe 100644 --- a/auth/detect/internal/externalaccount/externalaccount.go +++ b/auth/detect/internal/externalaccount/externalaccount.go @@ -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 ) @@ -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. @@ -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, @@ -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 @@ -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) +} diff --git a/auth/detect/internal/externalaccount/externalaccount_test.go b/auth/detect/internal/externalaccount/externalaccount_test.go index f2e85a74f83a..1a3dc067b64c 100644 --- a/auth/detect/internal/externalaccount/externalaccount_test.go +++ b/auth/detect/internal/externalaccount/externalaccount_test.go @@ -16,6 +16,7 @@ package externalaccount import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -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) @@ -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) @@ -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) @@ -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) { @@ -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) @@ -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) +} diff --git a/auth/detect/internal/externalaccount/file_provider.go b/auth/detect/internal/externalaccount/file_provider.go index bf02f280f895..cd48f40d2d14 100644 --- a/auth/detect/internal/externalaccount/file_provider.go +++ b/auth/detect/internal/externalaccount/file_provider.go @@ -26,6 +26,10 @@ import ( "cloud.google.com/go/auth/internal/internaldetect" ) +const ( + fileProviderType = "file" +) + type fileSubjectProvider struct { File string Format internaldetect.Format @@ -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 +} diff --git a/auth/detect/internal/externalaccount/file_provider_test.go b/auth/detect/internal/externalaccount/file_provider_test.go index b06e81d24f8b..2fa3b9511c95 100644 --- a/auth/detect/internal/externalaccount/file_provider_test.go +++ b/auth/detect/internal/externalaccount/file_provider_test.go @@ -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) + } }) } } diff --git a/auth/detect/internal/externalaccount/impersonate_test.go b/auth/detect/internal/externalaccount/impersonate_test.go index 30f662d4a318..0df1908c55e1 100644 --- a/auth/detect/internal/externalaccount/impersonate_test.go +++ b/auth/detect/internal/externalaccount/impersonate_test.go @@ -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", @@ -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", @@ -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 { diff --git a/auth/detect/internal/externalaccount/info.go b/auth/detect/internal/externalaccount/info.go new file mode 100644 index 000000000000..8e4b4379b41d --- /dev/null +++ b/auth/detect/internal/externalaccount/info.go @@ -0,0 +1,74 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import ( + "runtime" + "strings" + "unicode" +) + +var ( + // version is a package internal global variable for testing purposes. + version = runtime.Version +) + +// versionUnknown is only used when the runtime version cannot be determined. +const versionUnknown = "UNKNOWN" + +// goVersion returns a Go runtime version derived from the runtime environment +// that is modified to be suitable for reporting in a header, meaning it has no +// whitespace. If it is unable to determine the Go runtime version, it returns +// versionUnknown. +func goVersion() string { + const develPrefix = "devel +" + + s := version() + if strings.HasPrefix(s, develPrefix) { + s = s[len(develPrefix):] + if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 { + s = s[:p] + } + return s + } else if p := strings.IndexFunc(s, unicode.IsSpace); p >= 0 { + s = s[:p] + } + + notSemverRune := func(r rune) bool { + return !strings.ContainsRune("0123456789.", r) + } + + if strings.HasPrefix(s, "go1") { + s = s[2:] + var prerelease string + if p := strings.IndexFunc(s, notSemverRune); p >= 0 { + s, prerelease = s[:p], s[p:] + } + if strings.HasSuffix(s, ".") { + s += "0" + } else if strings.Count(s, ".") < 2 { + s += ".0" + } + if prerelease != "" { + // Some release candidates already have a dash in them. + if !strings.HasPrefix(prerelease, "-") { + prerelease = "-" + prerelease + } + s += prerelease + } + return s + } + return versionUnknown +} diff --git a/auth/detect/internal/externalaccount/info_test.go b/auth/detect/internal/externalaccount/info_test.go new file mode 100644 index 000000000000..de9e3c8432b6 --- /dev/null +++ b/auth/detect/internal/externalaccount/info_test.go @@ -0,0 +1,58 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccount + +import ( + "runtime" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestGoVersion(t *testing.T) { + testVersion := func(v string) func() string { + return func() string { + return v + } + } + for _, tst := range []struct { + v func() string + want string + }{ + { + testVersion("go1.19"), + "1.19.0", + }, + { + testVersion("go1.21-20230317-RC01"), + "1.21.0-20230317-RC01", + }, + { + testVersion("devel +abc1234"), + "abc1234", + }, + { + testVersion("this should be unknown"), + versionUnknown, + }, + } { + version = tst.v + got := goVersion() + if diff := cmp.Diff(got, tst.want); diff != "" { + t.Errorf("got(-),want(+):\n%s", diff) + } + } + version = runtime.Version +} diff --git a/auth/detect/internal/externalaccount/url_provider.go b/auth/detect/internal/externalaccount/url_provider.go index ee271d7a96a7..1f870728624c 100644 --- a/auth/detect/internal/externalaccount/url_provider.go +++ b/auth/detect/internal/externalaccount/url_provider.go @@ -26,8 +26,9 @@ import ( ) const ( - fileTypeText = "text" - fileTypeJSON = "json" + fileTypeText = "text" + fileTypeJSON = "json" + urlProviderType = "url" ) type urlSubjectProvider struct { @@ -81,5 +82,8 @@ func (sp *urlSubjectProvider) subjectToken(ctx context.Context) (string, error) default: return "", errors.New("detect: invalid credential_source file format type: " + sp.Format.Type) } +} +func (sp *urlSubjectProvider) providerType() string { + return urlProviderType } diff --git a/auth/detect/internal/externalaccount/url_provider_test.go b/auth/detect/internal/externalaccount/url_provider_test.go index bf478b7250b1..e9fb4da4730a 100644 --- a/auth/detect/internal/externalaccount/url_provider_test.go +++ b/auth/detect/internal/externalaccount/url_provider_test.go @@ -56,6 +56,9 @@ func TestRetrieveURLSubjectToken_Text(t *testing.T) { if want := "testTokenValue"; got != want { t.Errorf("got %q, want %q", got, want) } + if got, want := base.providerType(), urlProviderType; got != want { + t.Fatalf("got %q, want %q", got, want) + } } func TestRetrieveURLSubjectToken_Untyped(t *testing.T) { diff --git a/auth/detect/internal/externalaccountuser/externalaccountuser.go b/auth/detect/internal/externalaccountuser/externalaccountuser.go new file mode 100644 index 000000000000..6a94708c2e88 --- /dev/null +++ b/auth/detect/internal/externalaccountuser/externalaccountuser.go @@ -0,0 +1,110 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccountuser + +import ( + "context" + "errors" + "net/http" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/detect/internal/stsexchange" + "cloud.google.com/go/auth/internal" +) + +// Options stores the configuration for fetching tokens with external authorized +// user credentials. +type Options struct { + // Audience is the Secure Token Service (STS) audience which contains the + // resource name for the workforce pool and the provider identifier in that + // pool. + Audience string + // RefreshToken is the OAuth 2.0 refresh token. + RefreshToken string + // TokenURL is the STS token exchange endpoint for refresh. + TokenURL string + // TokenInfoURL is the STS endpoint URL for token introspection. Optional. + TokenInfoURL string + // ClientID is only required in conjunction with ClientSecret, as described + // below. + ClientID string + // ClientSecret is currently only required if token_info endpoint also needs + // to be called with the generated a cloud access token. When provided, STS + // will be called with additional basic authentication using client_id as + // username and client_secret as password. + ClientSecret string + // Scopes contains the desired scopes for the returned access token. + Scopes []string + + // Client for token request. + Client *http.Client +} + +func (c *Options) validate() bool { + return c.ClientID != "" && c.ClientSecret != "" && c.RefreshToken != "" && c.TokenURL != "" +} + +// NewTokenProvider returns a [cloud.google.com/go/auth.TokenProvider] +// configured with the provided options. +func NewTokenProvider(opts *Options) (auth.TokenProvider, error) { + if !opts.validate() { + return nil, errors.New("detect: invalid external_account_authorized_user configuration") + } + + tp := &tokenProvider{ + o: opts, + } + return auth.NewCachedTokenProvider(tp, nil), nil +} + +type tokenProvider struct { + o *Options +} + +func (tp *tokenProvider) Token(ctx context.Context) (*auth.Token, error) { + opts := tp.o + + clientAuth := stsexchange.ClientAuthentication{ + AuthStyle: auth.StyleInHeader, + ClientID: opts.ClientID, + ClientSecret: opts.ClientSecret, + } + headers := make(http.Header) + headers.Set("Content-Type", "application/x-www-form-urlencoded") + stsResponse, err := stsexchange.RefreshAccessToken(ctx, &stsexchange.Options{ + Client: opts.Client, + Endpoint: opts.TokenURL, + RefreshToken: opts.RefreshToken, + Authentication: clientAuth, + Headers: headers, + }) + if err != nil { + return nil, err + } + if stsResponse.ExpiresIn < 0 { + return nil, errors.New("detect: invalid expiry from security token service") + } + + // guarded by the wrapping with CachedTokenProvider + if stsResponse.RefreshToken != "" { + opts.RefreshToken = stsResponse.RefreshToken + } + return &auth.Token{ + Value: stsResponse.AccessToken, + Expiry: time.Now().UTC().Add(time.Duration(stsResponse.ExpiresIn) * time.Second), + Type: internal.TokenTypeBearer, + }, nil +} diff --git a/auth/detect/internal/externalaccountuser/externalaccountuser_test.go b/auth/detect/internal/externalaccountuser/externalaccountuser_test.go new file mode 100644 index 000000000000..279e771b0b87 --- /dev/null +++ b/auth/detect/internal/externalaccountuser/externalaccountuser_test.go @@ -0,0 +1,211 @@ +// Copyright 2023 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package externalaccountuser + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "cloud.google.com/go/auth/detect/internal/stsexchange" + "cloud.google.com/go/auth/internal" +) + +type testTokenServer struct { + URL string + Authorization string + ContentType string + Body string + ResponsePayload *stsexchange.TokenResponse + Response string + server *httptest.Server +} + +func TestExernalAccountAuthorizedUser_TokenRefreshWithRefreshTokenInResponse(t *testing.T) { + s := &testTokenServer{ + URL: "/", + Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=", + ContentType: "application/x-www-form-urlencoded", + Body: "grant_type=refresh_token&refresh_token=BBBBBBBBB", + ResponsePayload: &stsexchange.TokenResponse{ + ExpiresIn: 3600, + AccessToken: "AAAAAAA", + RefreshToken: "CCCCCCC", + }, + } + + s.startTestServer(t) + defer s.server.Close() + + opts := &Options{ + RefreshToken: "BBBBBBBBB", + TokenURL: s.server.URL, + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + Client: internal.CloneDefaultClient(), + } + tp, err := NewTokenProvider(opts) + if err != nil { + t.Fatalf("NewTokenProvider() = %v", err) + } + + token, err := tp.Token(context.Background()) + if err != nil { + t.Fatalf("Token() = %v", err) + } + if got, want := token.Value, "AAAAAAA"; got != want { + t.Fatalf("got %v, want %v", got, want) + } + if got, want := opts.RefreshToken, "CCCCCCC"; got != want { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExernalAccountAuthorizedUser_MinimumFieldsRequiredForRefresh(t *testing.T) { + s := &testTokenServer{ + URL: "/", + Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=", + ContentType: "application/x-www-form-urlencoded", + Body: "grant_type=refresh_token&refresh_token=BBBBBBBBB", + ResponsePayload: &stsexchange.TokenResponse{ + ExpiresIn: 3600, + AccessToken: "AAAAAAA", + }, + } + + s.startTestServer(t) + defer s.server.Close() + + opts := &Options{ + RefreshToken: "BBBBBBBBB", + TokenURL: s.server.URL, + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + Client: internal.CloneDefaultClient(), + } + ts, err := NewTokenProvider(opts) + if err != nil { + t.Fatalf("NewTokenProvider() = %v", err) + } + + token, err := ts.Token(context.Background()) + if err != nil { + t.Fatalf("Token() = %v", err) + } + if got, want := token.Value, "AAAAAAA"; got != want { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestExternalAccountAuthorizedUser_MissingRefreshFields(t *testing.T) { + s := &testTokenServer{ + URL: "/", + Authorization: "Basic Q0xJRU5UX0lEOkNMSUVOVF9TRUNSRVQ=", + ContentType: "application/x-www-form-urlencoded", + Body: "grant_type=refresh_token&refresh_token=BBBBBBBBB", + ResponsePayload: &stsexchange.TokenResponse{ + ExpiresIn: 3600, + AccessToken: "AAAAAAA", + }, + } + + s.startTestServer(t) + defer s.server.Close() + testCases := []struct { + name string + opts *Options + }{ + { + name: "empty config", + opts: &Options{}, + }, + { + name: "missing refresh token", + opts: &Options{ + TokenURL: s.server.URL, + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + }, + }, + { + name: "missing token url", + opts: &Options{ + RefreshToken: "BBBBBBBBB", + ClientID: "CLIENT_ID", + ClientSecret: "CLIENT_SECRET", + }, + }, + { + name: "missing client id", + opts: &Options{ + RefreshToken: "BBBBBBBBB", + TokenURL: s.server.URL, + ClientSecret: "CLIENT_SECRET", + }, + }, + { + name: "missing client secrect", + opts: &Options{ + RefreshToken: "BBBBBBBBB", + TokenURL: s.server.URL, + ClientID: "CLIENT_ID", + }, + }, + } + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + if _, err := NewTokenProvider(tt.opts); err == nil { + t.Fatalf("got nil, want an error") + } + }) + } +} + +func (s *testTokenServer) startTestServer(t *testing.T) { + t.Helper() + s.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got, want := r.URL.String(), s.URL; got != want { + t.Errorf("got %v, want %v", got, want) + } + headerAuth := r.Header.Get("Authorization") + if got, want := headerAuth, s.Authorization; got != want { + t.Errorf("got %v, want %v", got, want) + } + headerContentType := r.Header.Get("Content-Type") + if got, want := headerContentType, s.ContentType; got != want { + t.Errorf("got %v. want %v", got, want) + } + body, err := io.ReadAll(r.Body) + if err != nil { + t.Error(err) + } + if got, want := string(body), s.Body; got != want { + t.Errorf("got %q, want %q", got, want) + } + w.Header().Set("Content-Type", "application/json") + if s.ResponsePayload != nil { + content, err := json.Marshal(s.ResponsePayload) + if err != nil { + t.Error(err) + } + w.Write(content) + } else { + w.Write([]byte(s.Response)) + } + })) +} diff --git a/auth/detect/internal/externalaccount/sts_exchange.go b/auth/detect/internal/stsexchange/sts_exchange.go similarity index 57% rename from auth/detect/internal/externalaccount/sts_exchange.go rename to auth/detect/internal/stsexchange/sts_exchange.go index 509fc0f653b9..8d0e5d1b39a1 100644 --- a/auth/detect/internal/externalaccount/sts_exchange.go +++ b/auth/detect/internal/stsexchange/sts_exchange.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package externalaccount +package stsexchange import ( "context" @@ -28,50 +28,72 @@ import ( "cloud.google.com/go/auth/internal" ) -type exchangeOptions struct { - client *http.Client - endpoint string - request *stsTokenExchangeRequest - authentication clientAuthentication - headers http.Header - extraOpts map[string]interface{} +const ( + // GrantType for a sts exchange. + GrantType = "urn:ietf:params:oauth:grant-type:token-exchange" + // TokenType for a sts exchange. + TokenType = "urn:ietf:params:oauth:token-type:access_token" + + jwtTokenType = "urn:ietf:params:oauth:token-type:jwt" +) + +// Options stores the configuration for making an sts exchange request. +type Options struct { + Client *http.Client + Endpoint string + Request *TokenRequest + Authentication ClientAuthentication + Headers http.Header + // ExtraOpts are optional fields marshalled into the `options` field of the + // request body. + ExtraOpts map[string]interface{} + RefreshToken string +} + +// RefreshAccessToken performs the token exchange using a refresh token flow. +func RefreshAccessToken(ctx context.Context, opts *Options) (*TokenResponse, error) { + data := url.Values{} + data.Set("grant_type", "refresh_token") + data.Set("refresh_token", opts.RefreshToken) + return doRequest(ctx, opts, data) } -// exchangeToken performs an oauth2 token exchange with the provided endpoint. -// The first 4 fields are all mandatory. headers can be used to pass additional -// headers beyond the bare minimum required by the token exchange. options can -// be used to pass additional JSON-structured options to the remote server. -func exchangeToken(ctx context.Context, opts *exchangeOptions) (*stsTokenExchangeResponse, error) { +// ExchangeToken performs an oauth2 token exchange with the provided endpoint. +func ExchangeToken(ctx context.Context, opts *Options) (*TokenResponse, error) { data := url.Values{} - data.Set("audience", opts.request.Audience) - data.Set("grant_type", stsGrantType) - data.Set("requested_token_type", stsTokenType) - data.Set("subject_token_type", opts.request.SubjectTokenType) - data.Set("subject_token", opts.request.SubjectToken) - data.Set("scope", strings.Join(opts.request.Scope, " ")) - if opts.extraOpts != nil { - opts, err := json.Marshal(opts.extraOpts) + data.Set("audience", opts.Request.Audience) + data.Set("grant_type", GrantType) + data.Set("requested_token_type", TokenType) + data.Set("subject_token_type", opts.Request.SubjectTokenType) + data.Set("subject_token", opts.Request.SubjectToken) + data.Set("scope", strings.Join(opts.Request.Scope, " ")) + if opts.ExtraOpts != nil { + opts, err := json.Marshal(opts.ExtraOpts) if err != nil { return nil, fmt.Errorf("detect: failed to marshal additional options: %w", err) } data.Set("options", string(opts)) } - opts.authentication.InjectAuthentication(data, opts.headers) + return doRequest(ctx, opts, data) +} + +func doRequest(ctx context.Context, opts *Options, data url.Values) (*TokenResponse, error) { + opts.Authentication.InjectAuthentication(data, opts.Headers) encodedData := data.Encode() - req, err := http.NewRequestWithContext(ctx, "POST", opts.endpoint, strings.NewReader(encodedData)) + req, err := http.NewRequestWithContext(ctx, "POST", opts.Endpoint, strings.NewReader(encodedData)) if err != nil { return nil, fmt.Errorf("detect: failed to properly build http request: %w", err) } - for key, list := range opts.headers { + for key, list := range opts.Headers { for _, val := range list { req.Header.Add(key, val) } } req.Header.Set("Content-Length", strconv.Itoa(len(encodedData))) - resp, err := opts.client.Do(req) + resp, err := opts.Client.Do(req) if err != nil { return nil, fmt.Errorf("detect: invalid response from Secure Token Server: %w", err) } @@ -84,7 +106,7 @@ func exchangeToken(ctx context.Context, opts *exchangeOptions) (*stsTokenExchang if c := resp.StatusCode; c < http.StatusOK || c > http.StatusMultipleChoices { return nil, fmt.Errorf("detect: status code %d: %s", c, body) } - var stsResp stsTokenExchangeResponse + var stsResp TokenResponse if err := json.Unmarshal(body, &stsResp); err != nil { return nil, fmt.Errorf("detect: failed to unmarshal response body from Secure Token Server: %w", err) } @@ -92,9 +114,9 @@ func exchangeToken(ctx context.Context, opts *exchangeOptions) (*stsTokenExchang return &stsResp, nil } -// stsTokenExchangeRequest contains fields necessary to make an oauth2 token +// TokenRequest contains fields necessary to make an oauth2 token // exchange. -type stsTokenExchangeRequest struct { +type TokenRequest struct { ActingParty struct { ActorToken string ActorTokenType string @@ -108,19 +130,20 @@ type stsTokenExchangeRequest struct { SubjectTokenType string } -// stsTokenExchangeResponse is used to decode the remote server response during +// TokenResponse is used to decode the remote server response during // an oauth2 token exchange. -type stsTokenExchangeResponse struct { +type TokenResponse struct { AccessToken string `json:"access_token"` IssuedTokenType string `json:"issued_token_type"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` + RefreshToken string `json:"refresh_token"` } -// clientAuthentication represents an OAuth client ID and secret and the +// ClientAuthentication represents an OAuth client ID and secret and the // mechanism for passing these credentials as stated in rfc6749#2.3.1. -type clientAuthentication struct { +type ClientAuthentication struct { AuthStyle auth.Style ClientID string ClientSecret string @@ -129,7 +152,7 @@ type clientAuthentication struct { // InjectAuthentication is used to add authentication to a Secure Token Service // exchange request. It modifies either the passed url.Values or http.Header // depending on the desired authentication format. -func (c *clientAuthentication) InjectAuthentication(values url.Values, headers http.Header) { +func (c *ClientAuthentication) InjectAuthentication(values url.Values, headers http.Header) { if c.ClientID == "" || c.ClientSecret == "" || values == nil || headers == nil { return } diff --git a/auth/detect/internal/externalaccount/sts_exchange_test.go b/auth/detect/internal/stsexchange/sts_exchange_test.go similarity index 89% rename from auth/detect/internal/externalaccount/sts_exchange_test.go rename to auth/detect/internal/stsexchange/sts_exchange_test.go index aa12d9fcc038..03c1024520bd 100644 --- a/auth/detect/internal/externalaccount/sts_exchange_test.go +++ b/auth/detect/internal/stsexchange/sts_exchange_test.go @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -package externalaccount +package stsexchange import ( "context" @@ -29,21 +29,21 @@ import ( ) var ( - clientAuth = clientAuthentication{ + clientAuth = ClientAuthentication{ AuthStyle: auth.StyleInHeader, ClientID: clientID, ClientSecret: clientSecret, } - tokenRequest = stsTokenExchangeRequest{ + tokReq = TokenRequest{ ActingParty: struct { ActorToken string ActorTokenType string }{}, - GrantType: stsGrantType, + GrantType: GrantType, Resource: "", Audience: "32555940559.apps.googleusercontent.com", Scope: []string{"https://www.googleapis.com/auth/devstorage.full_control"}, - RequestedTokenType: stsTokenType, + RequestedTokenType: TokenType, SubjectToken: "Sample.Subject.Token", SubjectTokenType: jwtTokenType, } @@ -53,9 +53,9 @@ var ( func TestExchangeToken(t *testing.T) { requestbody := "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=Sample.Subject.Token&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Ajwt" - wantToken := stsTokenExchangeResponse{ + wantToken := TokenResponse{ AccessToken: "Sample.Access.Token", - IssuedTokenType: stsTokenType, + IssuedTokenType: TokenType, TokenType: internal.TokenTypeBearer, ExpiresIn: 3600, Scope: "https://www.googleapis.com/auth/cloud-platform", @@ -85,13 +85,13 @@ func TestExchangeToken(t *testing.T) { headers := http.Header{} headers.Set("Content-Type", "application/x-www-form-urlencoded") - resp, err := exchangeToken(context.Background(), &exchangeOptions{ - client: internal.CloneDefaultClient(), - endpoint: ts.URL, - request: &tokenRequest, - authentication: clientAuth, - headers: headers, - extraOpts: nil, + resp, err := ExchangeToken(context.Background(), &Options{ + Client: internal.CloneDefaultClient(), + Endpoint: ts.URL, + Request: &tokReq, + Authentication: clientAuth, + Headers: headers, + ExtraOpts: nil, }) if err != nil { t.Fatalf("exchangeToken() = %v", err) @@ -111,13 +111,13 @@ func TestExchangeToken_Err(t *testing.T) { headers := http.Header{} headers.Set("Content-Type", "application/x-www-form-urlencoded") - if _, err := exchangeToken(context.Background(), &exchangeOptions{ - client: internal.CloneDefaultClient(), - endpoint: ts.URL, - request: &tokenRequest, - authentication: clientAuth, - headers: headers, - extraOpts: nil, + if _, err := ExchangeToken(context.Background(), &Options{ + Client: internal.CloneDefaultClient(), + Endpoint: ts.URL, + Request: &tokReq, + Authentication: clientAuth, + Headers: headers, + ExtraOpts: nil, }); err == nil { t.Errorf("got nil, want an error") } @@ -201,13 +201,13 @@ func TestExchangeToken_Opts(t *testing.T) { inputOpts["one"] = firstOption inputOpts["two"] = secondOption - exchangeToken(context.Background(), &exchangeOptions{ - client: internal.CloneDefaultClient(), - endpoint: ts.URL, - request: &tokenRequest, - authentication: clientAuth, - headers: headers, - extraOpts: inputOpts, + ExchangeToken(context.Background(), &Options{ + Client: internal.CloneDefaultClient(), + Endpoint: ts.URL, + Request: &tokReq, + Authentication: clientAuth, + Headers: headers, + ExtraOpts: inputOpts, }) } @@ -215,8 +215,8 @@ var ( clientID = "rbrgnognrhongo3bi4gb9ghg9g" clientSecret = "notsosecret" audience = []string{"32555940559.apps.googleusercontent.com"} - grantType = []string{stsGrantType} - requestedTokenType = []string{stsTokenType} + grantType = []string{GrantType} + requestedTokenType = []string{TokenType} subjectTokenType = []string{jwtTokenType} subjectToken = []string{"eyJhbGciOiJSUzI1NiIsImtpZCI6IjJjNmZhNmY1OTUwYTdjZTQ2NWZjZjI0N2FhMGIwOTQ4MjhhYzk1MmMiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiIzMjU1NTk0MDU1OS5hcHBzLmdvb2dsZXVzZXJjb250ZW50LmNvbSIsImF1ZCI6IjMyNTU1OTQwNTU5LmFwcHMuZ29vZ2xldXNlcmNvbnRlbnQuY29tIiwic3ViIjoiMTEzMzE4NTQxMDA5MDU3Mzc4MzI4IiwiaGQiOiJnb29nbGUuY29tIiwiZW1haWwiOiJpdGh1cmllbEBnb29nbGUuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImF0X2hhc2giOiI5OVJVYVFrRHJsVDFZOUV0SzdiYXJnIiwiaWF0IjoxNjAxNTgxMzQ5LCJleHAiOjE2MDE1ODQ5NDl9.SZ-4DyDcogDh_CDUKHqPCiT8AKLg4zLMpPhGQzmcmHQ6cJiV0WRVMf5Lq911qsvuekgxfQpIdKNXlD6yk3FqvC2rjBbuEztMF-OD_2B8CEIYFlMLGuTQimJlUQksLKM-3B2ITRDCxnyEdaZik0OVssiy1CBTsllS5MgTFqic7w8w0Cd6diqNkfPFZRWyRYsrRDRlHHbH5_TUnv2wnLVHBHlNvU4wU2yyjDIoqOvTRp8jtXdq7K31CDhXd47-hXsVFQn2ZgzuUEAkH2Q6NIXACcVyZOrjBcZiOQI9IRWz-g03LzbzPSecO7I8dDrhqUSqMrdNUz_f8Kr8JFhuVMfVug"} scope = []string{"https://www.googleapis.com/auth/devstorage.full_control"} @@ -236,7 +236,7 @@ func TestClientAuthentication_InjectHeaderAuthentication(t *testing.T) { "Content-Type": ContentType, } - headerAuthentication := clientAuthentication{ + headerAuthentication := ClientAuthentication{ AuthStyle: auth.StyleInHeader, ClientID: clientID, ClientSecret: clientSecret, @@ -278,7 +278,7 @@ func TestClientAuthentication_ParamsAuthentication(t *testing.T) { headerP := http.Header{ "Content-Type": ContentType, } - paramsAuthentication := clientAuthentication{ + paramsAuthentication := ClientAuthentication{ AuthStyle: auth.StyleInParams, ClientID: clientID, ClientSecret: clientSecret, diff --git a/auth/internal/internaldetect/filetype.go b/auth/internal/internaldetect/filetype.go index 7dc2ebc00842..204eb30e41c1 100644 --- a/auth/internal/internaldetect/filetype.go +++ b/auth/internal/internaldetect/filetype.go @@ -70,6 +70,19 @@ type ExternalAccountFile struct { UniverseDomain string `json:"universe_domain"` } +// ExternalAccountAuthorizedUserFile representation. +type ExternalAccountAuthorizedUserFile struct { + Type string `json:"type"` + Audience string `json:"audience"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + RefreshToken string `json:"refresh_token"` + TokenURL string `json:"token_url"` + TokenInfoURL string `json:"token_info_url"` + RevokeURL string `json:"revoke_url"` + QuotaProjectID string `json:"quota_project_id"` +} + // CredentialSource stores the information necessary to retrieve the credentials for the STS exchange. // // One field amongst File, URL, and Executable should be filled, depending on the kind of credential in question. diff --git a/auth/internal/internaldetect/internaldetect.go b/auth/internal/internaldetect/internaldetect.go index a9b4e79a204b..88c5eb37fcb2 100644 --- a/auth/internal/internaldetect/internaldetect.go +++ b/auth/internal/internaldetect/internaldetect.go @@ -48,6 +48,9 @@ const ( ExternalAccountKey // GDCHServiceAccountKey represents a GDCH file type. GDCHServiceAccountKey + // ExternalAccountAuthorizedUserKey represents a external account authorized + // user file type. + ExternalAccountAuthorizedUserKey ) // parseCredentialType returns the associated filetype based on the parsed @@ -62,6 +65,8 @@ func parseCredentialType(typeString string) CredentialType { return ImpersonatedServiceAccountKey case "external_account": return ExternalAccountKey + case "external_account_authorized_user": + return ExternalAccountAuthorizedUserKey case "gdch_service_account": return GDCHServiceAccountKey default: diff --git a/auth/internal/internaldetect/parse.go b/auth/internal/internaldetect/parse.go index 97fb09204ca2..ed2025dd612a 100644 --- a/auth/internal/internaldetect/parse.go +++ b/auth/internal/internaldetect/parse.go @@ -55,6 +55,16 @@ func ParseExternalAccount(b []byte) (*ExternalAccountFile, error) { return f, nil } +// ParseExternalAccountAuthorizedUser parses bytes into a +// [ExternalAccountAuthorizedUserFile]. +func ParseExternalAccountAuthorizedUser(b []byte) (*ExternalAccountAuthorizedUserFile, error) { + var f *ExternalAccountAuthorizedUserFile + if err := json.Unmarshal(b, &f); err != nil { + return nil, err + } + return f, nil +} + // ParseImpersonatedServiceAccount parses bytes into a // [ImpersonatedServiceAccountFile]. func ParseImpersonatedServiceAccount(b []byte) (*ImpersonatedServiceAccountFile, error) { diff --git a/auth/internal/internaldetect/parse_test.go b/auth/internal/internaldetect/parse_test.go index af0c84259903..a61a038420be 100644 --- a/auth/internal/internaldetect/parse_test.go +++ b/auth/internal/internaldetect/parse_test.go @@ -279,3 +279,28 @@ func TestParseExternalAccount_Cmd(t *testing.T) { t.Errorf("(-want +got):\n%s", diff) } } + +func TestParseExternalAccountAuthorizedUser(t *testing.T) { + b, err := os.ReadFile("../testdata/exaccount_user.json") + if err != nil { + t.Fatal(err) + } + got, err := ParseExternalAccountAuthorizedUser(b) + if err != nil { + t.Fatal(err) + } + want := &ExternalAccountAuthorizedUserFile{ + Type: "external_account_authorized_user", + Audience: "//iam.googleapis.com/locations/global/workforcePools/$POOL_ID/providers/$PROVIDER_ID", + ClientID: "abc123.apps.googleusercontent.com", + ClientSecret: "shh", + RefreshToken: "refreshing", + TokenURL: "https://sts.googleapis.com/v1/oauthtoken", + TokenInfoURL: "https://sts.googleapis.com/v1/info", + RevokeURL: "https://sts.googleapis.com/v1/revoke", + QuotaProjectID: "fake_project2", + } + if diff := cmp.Diff(want, got); diff != "" { + t.Errorf("(-want +got):\n%s", diff) + } +} diff --git a/auth/internal/testdata/exaccount_user.json b/auth/internal/testdata/exaccount_user.json new file mode 100644 index 000000000000..c15072592f34 --- /dev/null +++ b/auth/internal/testdata/exaccount_user.json @@ -0,0 +1,11 @@ +{ + "type": "external_account_authorized_user", + "audience": "//iam.googleapis.com/locations/global/workforcePools/$POOL_ID/providers/$PROVIDER_ID", + "client_id": "abc123.apps.googleusercontent.com", + "client_secret": "shh", + "refresh_token": "refreshing", + "token_url": "https://sts.googleapis.com/v1/oauthtoken", + "token_info_url": "https://sts.googleapis.com/v1/info", + "revoke_url": "https://sts.googleapis.com/v1/revoke", + "quota_project_id": "fake_project2" +}