From 5823db5d633069999b58b9131a7f9cd77e82c899 Mon Sep 17 00:00:00 2001
From: Cody Oss <6331106+codyoss@users.noreply.github.com>
Date: Wed, 18 Oct 2023 12:44:44 -0500
Subject: [PATCH] feat(auth): port external account changes (#8697)

- https://github.com/golang/oauth2/commit/18352fc4335d95cd4b558f7c914898f1c069754e
- https://github.com/golang/oauth2/commit/43b6a7ba1972152df70cd1e5ac7705e981df5f66
---
 auth/detect/detect_test.go                    |  58 +++++
 auth/detect/doc.go                            |   2 +
 auth/detect/filetypes.go                      |  25 +++
 .../internal/externalaccount/aws_provider.go  |   5 +
 .../externalaccount/aws_provider_test.go      |   4 +
 .../externalaccount/executable_provider.go    |   5 +
 .../executable_provider_test.go               |   3 +
 .../externalaccount/externalaccount.go        |  37 +--
 .../externalaccount/externalaccount_test.go   |  13 ++
 .../internal/externalaccount/file_provider.go |   8 +
 .../externalaccount/file_provider_test.go     |   4 +-
 .../externalaccount/impersonate_test.go       |  13 +-
 auth/detect/internal/externalaccount/info.go  |  74 ++++++
 .../internal/externalaccount/info_test.go     |  58 +++++
 .../internal/externalaccount/url_provider.go  |   8 +-
 .../externalaccount/url_provider_test.go      |   3 +
 .../externalaccountuser.go                    | 110 +++++++++
 .../externalaccountuser_test.go               | 211 ++++++++++++++++++
 .../sts_exchange.go                           |  89 +++++---
 .../sts_exchange_test.go                      |  64 +++---
 auth/internal/internaldetect/filetype.go      |  13 ++
 .../internal/internaldetect/internaldetect.go |   5 +
 auth/internal/internaldetect/parse.go         |  10 +
 auth/internal/internaldetect/parse_test.go    |  25 +++
 auth/internal/testdata/exaccount_user.json    |  11 +
 25 files changed, 771 insertions(+), 87 deletions(-)
 create mode 100644 auth/detect/internal/externalaccount/info.go
 create mode 100644 auth/detect/internal/externalaccount/info_test.go
 create mode 100644 auth/detect/internal/externalaccountuser/externalaccountuser.go
 create mode 100644 auth/detect/internal/externalaccountuser/externalaccountuser_test.go
 rename auth/detect/internal/{externalaccount => stsexchange}/sts_exchange.go (57%)
 rename auth/detect/internal/{externalaccount => stsexchange}/sts_exchange_test.go (89%)
 create mode 100644 auth/internal/testdata/exaccount_user.json

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"
+}