diff --git a/auth/credentials/idtoken/file.go b/auth/credentials/idtoken/file.go index 2cde8164d2b3..87fab751fbdc 100644 --- a/auth/credentials/idtoken/file.go +++ b/auth/credentials/idtoken/file.go @@ -22,6 +22,7 @@ import ( "cloud.google.com/go/auth" "cloud.google.com/go/auth/credentials/impersonate" + intimpersonate "cloud.google.com/go/auth/credentials/internal/impersonate" "cloud.google.com/go/auth/internal" "cloud.google.com/go/auth/internal/credsfile" "github.com/googleapis/gax-go/v2/internallog" @@ -44,38 +45,31 @@ func credsFromDefault(creds *auth.Credentials, opts *Options) (*auth.Credentials if err != nil { return nil, err } - opts2LO := &auth.Options2LO{ - Email: f.ClientEmail, - PrivateKey: []byte(f.PrivateKey), - PrivateKeyID: f.PrivateKeyID, - TokenURL: f.TokenURL, - UseIDToken: true, - Logger: internallog.New(opts.Logger), - } - if opts2LO.TokenURL == "" { - opts2LO.TokenURL = jwtTokenURL - } - - var customClaims map[string]interface{} - if opts != nil { - customClaims = opts.CustomClaims - } - if customClaims == nil { - customClaims = make(map[string]interface{}) - } - customClaims["target_audience"] = opts.Audience - - opts2LO.PrivateClaims = customClaims - tp, err := auth.New2LOTokenProvider(opts2LO) - if err != nil { - return nil, err + var tp auth.TokenProvider + if resolveUniverseDomain(f) == internal.DefaultUniverseDomain { + tp, err = new2LOTokenProvider(f, opts) + if err != nil { + return nil, err + } + } else { + // In case of non-GDU universe domain, use IAM. + tp = intimpersonate.IDTokenIAMOptions{ + Client: opts.client(), + Logger: internallog.New(opts.Logger), + // Pass the credentials universe domain to configure the endpoint. + UniverseDomain: auth.CredentialsPropertyFunc(creds.UniverseDomain), + ServiceAccountEmail: f.ClientEmail, + GenerateIDTokenRequest: intimpersonate.GenerateIDTokenRequest{ + Audience: opts.Audience, + }, + } } tp = auth.NewCachedTokenProvider(tp, nil) return auth.NewCredentials(&auth.CredentialsOptions{ TokenProvider: tp, JSON: b, - ProjectIDProvider: internal.StaticCredentialsProperty(f.ProjectID), - UniverseDomainProvider: internal.StaticCredentialsProperty(f.UniverseDomain), + ProjectIDProvider: auth.CredentialsPropertyFunc(creds.ProjectID), + UniverseDomainProvider: auth.CredentialsPropertyFunc(creds.UniverseDomain), }), nil case credsfile.ImpersonatedServiceAccountKey, credsfile.ExternalAccountKey: type url struct { @@ -110,3 +104,39 @@ func credsFromDefault(creds *auth.Credentials, opts *Options) (*auth.Credentials return nil, fmt.Errorf("idtoken: unsupported credentials type: %v", t) } } + +func new2LOTokenProvider(f *credsfile.ServiceAccountFile, opts *Options) (auth.TokenProvider, error) { + opts2LO := &auth.Options2LO{ + Email: f.ClientEmail, + PrivateKey: []byte(f.PrivateKey), + PrivateKeyID: f.PrivateKeyID, + TokenURL: f.TokenURL, + UseIDToken: true, + Logger: internallog.New(opts.Logger), + } + if opts2LO.TokenURL == "" { + opts2LO.TokenURL = jwtTokenURL + } + + var customClaims map[string]interface{} + if opts != nil { + customClaims = opts.CustomClaims + } + if customClaims == nil { + customClaims = make(map[string]interface{}) + } + customClaims["target_audience"] = opts.Audience + + opts2LO.PrivateClaims = customClaims + return auth.New2LOTokenProvider(opts2LO) +} + +// resolveUniverseDomain returns the default service domain for a given +// Cloud universe. This is the universe domain configured for the credentials, +// which will be used in endpoint. +func resolveUniverseDomain(f *credsfile.ServiceAccountFile) string { + if f.UniverseDomain != "" { + return f.UniverseDomain + } + return internal.DefaultUniverseDomain +} diff --git a/auth/credentials/idtoken/idtoken.go b/auth/credentials/idtoken/idtoken.go index 2e9a5d3ede39..86db9525df2a 100644 --- a/auth/credentials/idtoken/idtoken.go +++ b/auth/credentials/idtoken/idtoken.go @@ -86,6 +86,11 @@ type Options struct { // when fetching tokens. If provided this should be a fully-authenticated // client. Optional. Client *http.Client + // UniverseDomain is the default service domain for a given Cloud universe. + // The default value is "googleapis.com". This is the universe domain + // configured for the client, which will be compared to the universe domain + // that is separately configured for the credentials. Optional. + UniverseDomain string // Logger is used for debug logging. If provided, logging will be enabled // at the loggers configured level. By default logging is disabled unless // enabled by setting GOOGLE_SDK_GO_LOGGING_LEVEL in which case a default diff --git a/auth/credentials/idtoken/idtoken_test.go b/auth/credentials/idtoken/idtoken_test.go index e50ca0a03d1e..c63d1291d2a7 100644 --- a/auth/credentials/idtoken/idtoken_test.go +++ b/auth/credentials/idtoken/idtoken_test.go @@ -15,14 +15,19 @@ package idtoken import ( + "bytes" "context" "encoding/json" "fmt" + "io" + "log/slog" "net/http" "net/http/httptest" "os" + "strings" "testing" + "cloud.google.com/go/auth/credentials/internal/impersonate" "cloud.google.com/go/auth/internal" "cloud.google.com/go/auth/internal/credsfile" ) @@ -66,7 +71,8 @@ func TestNewCredentials_Validate(t *testing.T) { } } -func TestNewCredentials_ServiceAccount_NoClient(t *testing.T) { +func TestNewCredentials_ServiceAccount(t *testing.T) { + ctx := context.Background() wantTok, _ := createRS256JWT(t) b, err := os.ReadFile("../../internal/testdata/sa.json") if err != nil { @@ -97,13 +103,110 @@ func TestNewCredentials_ServiceAccount_NoClient(t *testing.T) { if err != nil { t.Fatal(err) } - tok, err := creds.Token(context.Background()) + tok, err := creds.Token(ctx) if err != nil { t.Fatalf("tp.Token() = %v", err) } if tok.Value != wantTok { t.Errorf("got %q, want %q", tok.Value, wantTok) } + if got, _ := creds.UniverseDomain(ctx); got != internal.DefaultUniverseDomain { + t.Errorf("got %q, want %q", got, internal.DefaultUniverseDomain) + } +} + +func TestNewCredentials_ServiceAccount_UniverseDomain(t *testing.T) { + wantAudience := "aud" + wantClientEmail := "gopher@fake_project.iam.gserviceaccount.com" + wantUniverseDomain := "example.com" + wantTok := "id-token" + client := &http.Client{ + Transport: RoundTripFn(func(req *http.Request) *http.Response { + defer req.Body.Close() + b, err := io.ReadAll(req.Body) + if err != nil { + t.Error(err) + } + var r impersonate.GenerateIDTokenRequest + if err := json.Unmarshal(b, &r); err != nil { + t.Error(err) + } + if r.Audience != wantAudience { + t.Errorf("got %q, want %q", r.Audience, wantAudience) + } + if r.IncludeEmail { + t.Errorf("got %t, want %t", r.IncludeEmail, false) + } + if !strings.Contains(req.URL.Path, wantClientEmail) { + t.Errorf("got %q, want %q", req.URL.Path, wantClientEmail) + } + if !strings.Contains(req.URL.Hostname(), wantUniverseDomain) { + t.Errorf("got %q, want %q", req.URL.Hostname(), wantUniverseDomain) + } + if !strings.Contains(req.URL.Path, "generateIdToken") { + t.Fatal("path must contain 'generateIdToken'") + } + + resp := impersonate.GenerateIDTokenResponse{ + Token: wantTok, + } + b, err = json.Marshal(&resp) + if err != nil { + t.Fatalf("unable to marshal response: %v", err) + } + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(bytes.NewReader(b)), + Header: http.Header{}, + } + }), + } + + ctx := context.Background() + creds, err := NewCredentials(&Options{ + Audience: wantAudience, + CredentialsFile: "../../internal/testdata/sa_universe_domain.json", + Client: client, + UniverseDomain: wantUniverseDomain, + }) + if err != nil { + t.Fatal(err) + } + tok, err := creds.Token(ctx) + if err != nil { + t.Fatalf("tp.Token() = %v", err) + } + if tok.Value != wantTok { + t.Errorf("got %q, want %q", tok.Value, wantTok) + } + if got, _ := creds.UniverseDomain(ctx); got != wantUniverseDomain { + t.Errorf("got %q, want %q", got, wantUniverseDomain) + } +} + +func TestNewCredentials_ServiceAccount_UniverseDomain_NoClient(t *testing.T) { + wantUniverseDomain := "example.com" + ctx := context.Background() + creds, err := NewCredentials(&Options{ + Audience: "aud", + CredentialsFile: "../../internal/testdata/sa_universe_domain.json", + UniverseDomain: wantUniverseDomain, + }) + if err != nil { + t.Fatal(err) + } + // To test client creation and usage without a mock client, we must expect a failed token request. + _, err = creds.Token(ctx) + if err == nil { + t.Fatal("token call to example.com did not fail") + } + // Assert that the failed token request targeted the universe domain. + if !strings.Contains(err.Error(), wantUniverseDomain) { + t.Errorf("got %q, want %q", err.Error(), wantUniverseDomain) + } + if got, _ := creds.UniverseDomain(ctx); got != wantUniverseDomain { + t.Errorf("got %q, want %q", got, wantUniverseDomain) + } } type mockTransport struct { @@ -147,6 +250,7 @@ func TestNewCredentials_ImpersonatedAndExternal(t *testing.T) { "foo": "bar", }, Client: client, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), } if tt.file != "" { opts.CredentialsFile = tt.file diff --git a/auth/credentials/impersonate/idtoken.go b/auth/credentials/impersonate/idtoken.go index 835b8f8d2ddc..1b7671f663a5 100644 --- a/auth/credentials/impersonate/idtoken.go +++ b/auth/credentials/impersonate/idtoken.go @@ -15,17 +15,13 @@ package impersonate import ( - "bytes" - "context" - "encoding/json" "errors" - "fmt" "log/slog" "net/http" - "time" "cloud.google.com/go/auth" "cloud.google.com/go/auth/credentials" + "cloud.google.com/go/auth/credentials/internal/impersonate" "cloud.google.com/go/auth/httptransport" "cloud.google.com/go/auth/internal" "github.com/googleapis/gax-go/v2/internallog" @@ -57,6 +53,11 @@ type IDTokenOptions struct { // when fetching tokens. If provided this should be a fully-authenticated // client. Optional. Client *http.Client + // UniverseDomain is the default service domain for a given Cloud universe. + // The default value is "googleapis.com". This is the universe domain + // configured for the client, which will be compared to the universe domain + // that is separately configured for the credentials. Optional. + UniverseDomain string // Logger is used for debug logging. If provided, logging will be enabled // at the loggers configured level. By default logging is disabled unless // enabled by setting GOOGLE_SDK_GO_LOGGING_LEVEL in which case a default @@ -90,14 +91,12 @@ func NewIDTokenCredentials(opts *IDTokenOptions) (*auth.Credentials, error) { if err := opts.validate(); err != nil { return nil, err } - client := opts.Client creds := opts.Credentials logger := internallog.New(opts.Logger) if client == nil { var err error if creds == nil { - // TODO: test not signed jwt more creds, err = credentials.DetectDefault(&credentials.DetectOptions{ Scopes: []string{defaultScope}, UseSelfSignedJWT: true, @@ -108,89 +107,34 @@ func NewIDTokenCredentials(opts *IDTokenOptions) (*auth.Credentials, error) { } } client, err = httptransport.NewClient(&httptransport.Options{ - Credentials: creds, - Logger: logger, + Credentials: creds, + UniverseDomain: opts.UniverseDomain, + Logger: logger, }) if err != nil { return nil, err } } - itp := impersonatedIDTokenProvider{ - client: client, - targetPrincipal: opts.TargetPrincipal, - audience: opts.Audience, - includeEmail: opts.IncludeEmail, - logger: logger, - } + universeDomainProvider := resolveUniverseDomainProvider(creds) + delegates := make([]string, len(opts.Delegates)) for _, v := range opts.Delegates { - itp.delegates = append(itp.delegates, formatIAMServiceAccountName(v)) + delegates = append(delegates, internal.FormatIAMServiceAccountResource(v)) } - - var udp auth.CredentialsPropertyProvider - if creds != nil { - udp = auth.CredentialsPropertyFunc(creds.UniverseDomain) + iamOpts := impersonate.IDTokenIAMOptions{ + Client: client, + Logger: logger, + // Pass the credentials universe domain provider to configure the endpoint. + UniverseDomain: universeDomainProvider, + ServiceAccountEmail: opts.TargetPrincipal, + GenerateIDTokenRequest: impersonate.GenerateIDTokenRequest{ + Audience: opts.Audience, + IncludeEmail: opts.IncludeEmail, + Delegates: delegates, + }, } return auth.NewCredentials(&auth.CredentialsOptions{ - TokenProvider: auth.NewCachedTokenProvider(itp, nil), - UniverseDomainProvider: udp, + TokenProvider: auth.NewCachedTokenProvider(iamOpts, nil), + UniverseDomainProvider: universeDomainProvider, }), nil } - -type generateIDTokenRequest struct { - Audience string `json:"audience"` - IncludeEmail bool `json:"includeEmail"` - Delegates []string `json:"delegates,omitempty"` -} - -type generateIDTokenResponse struct { - Token string `json:"token"` -} - -type impersonatedIDTokenProvider struct { - client *http.Client - logger *slog.Logger - - targetPrincipal string - audience string - includeEmail bool - delegates []string -} - -func (i impersonatedIDTokenProvider) Token(ctx context.Context) (*auth.Token, error) { - genIDTokenReq := generateIDTokenRequest{ - Audience: i.audience, - IncludeEmail: i.includeEmail, - Delegates: i.delegates, - } - bodyBytes, err := json.Marshal(genIDTokenReq) - if err != nil { - return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err) - } - - url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal)) - req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) - if err != nil { - return nil, fmt.Errorf("impersonate: unable to create request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - i.logger.DebugContext(ctx, "impersonated idtoken request", "request", internallog.HTTPRequest(req, bodyBytes)) - resp, body, err := internal.DoRequest(i.client, req) - if err != nil { - return nil, fmt.Errorf("impersonate: unable to generate ID token: %w", err) - } - i.logger.DebugContext(ctx, "impersonated idtoken response", "response", internallog.HTTPResponse(resp, body)) - if c := resp.StatusCode; c < 200 || c > 299 { - return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) - } - - var generateIDTokenResp generateIDTokenResponse - if err := json.Unmarshal(body, &generateIDTokenResp); err != nil { - return nil, fmt.Errorf("impersonate: unable to parse response: %w", err) - } - return &auth.Token{ - Value: generateIDTokenResp.Token, - // Generated ID tokens are good for one hour. - Expiry: time.Now().Add(1 * time.Hour), - }, nil -} diff --git a/auth/credentials/impersonate/idtoken_test.go b/auth/credentials/impersonate/idtoken_test.go index 70dfbc8d44eb..06fdb552e484 100644 --- a/auth/credentials/impersonate/idtoken_test.go +++ b/auth/credentials/impersonate/idtoken_test.go @@ -20,32 +20,69 @@ import ( "encoding/json" "io" "net/http" + "strings" "testing" + + "cloud.google.com/go/auth/credentials/internal/impersonate" ) func TestNewIDTokenCredentials(t *testing.T) { ctx := context.Background() tests := []struct { - name string - aud string - targetPrincipal string - wantErr bool + name string + config IDTokenOptions + wantErr bool + wantUniverseDomain string }{ { - name: "missing aud", - targetPrincipal: "foo@project-id.iam.gserviceaccount.com", - wantErr: true, + name: "missing aud", + config: IDTokenOptions{ + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + }, + wantErr: true, }, { - name: "missing targetPrincipal", - aud: "http://example.com/", + name: "missing targetPrincipal", + config: IDTokenOptions{ + Audience: "http://example.com/", + }, wantErr: true, }, { - name: "works", - aud: "http://example.com/", - targetPrincipal: "foo@project-id.iam.gserviceaccount.com", - wantErr: false, + name: "works", + config: IDTokenOptions{ + Audience: "http://example.com/", + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + }, + wantUniverseDomain: "googleapis.com", + }, + { + name: "universe domain from options", + config: IDTokenOptions{ + Audience: "http://example.com/", + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + UniverseDomain: "example.com", + }, + wantUniverseDomain: "googleapis.com", // From creds, not IDTokenOptions.UniverseDomain + }, + { + name: "universe domain from options and credentials", + config: IDTokenOptions{ + Audience: "http://example.com/", + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + UniverseDomain: "NOT.example.com", + Credentials: staticCredentials("example.com"), + }, + wantUniverseDomain: "example.com", // From creds, not IDTokenOptions.UniverseDomain + }, + { + name: "universe domain from credentials", + config: IDTokenOptions{ + Audience: "http://example.com/", + TargetPrincipal: "foo@project-id.iam.gserviceaccount.com", + Credentials: staticCredentials("example.com"), + }, + wantUniverseDomain: "example.com", }, } @@ -60,15 +97,24 @@ func TestNewIDTokenCredentials(t *testing.T) { if err != nil { t.Error(err) } - var r generateIDTokenRequest + var r impersonate.GenerateIDTokenRequest if err := json.Unmarshal(b, &r); err != nil { t.Error(err) } - if r.Audience != tt.aud { - t.Errorf("got %q, want %q", r.Audience, tt.aud) + if r.Audience != tt.config.Audience { + t.Errorf("got %q, want %q", r.Audience, tt.config.Audience) + } + if !strings.Contains(req.URL.Path, tt.config.TargetPrincipal) { + t.Errorf("got %q, want %q", req.URL.Path, tt.config.TargetPrincipal) + } + if !strings.Contains(req.URL.Hostname(), tt.wantUniverseDomain) { + t.Errorf("got %q, want %q", req.URL.Hostname(), tt.wantUniverseDomain) + } + if !strings.Contains(req.URL.Path, "generateIdToken") { + t.Error("path must contain 'generateIdToken'") } - resp := generateIDTokenResponse{ + resp := impersonate.GenerateIDTokenResponse{ Token: idTok, } b, err = json.Marshal(&resp) @@ -82,24 +128,28 @@ func TestNewIDTokenCredentials(t *testing.T) { } }), } - creds, err := NewIDTokenCredentials(&IDTokenOptions{ - Audience: tt.aud, - TargetPrincipal: tt.targetPrincipal, - Client: client, - }, - ) - if tt.wantErr && err != nil { - return + if tt.config.Credentials == nil { + tt.config.Client = client } + creds, err := NewIDTokenCredentials(&tt.config) if err != nil { - t.Fatal(err) + if !tt.wantErr { + t.Errorf("err: %v", err) + } + return } - tok, err := creds.Token(ctx) - if err != nil { - t.Fatal(err) + // Static config.Credentials is invalid for Token request, skip. + if tt.config.Credentials == nil { + tok, err := creds.Token(ctx) + if err != nil { + t.Error(err) + } + if tok.Value != idTok { + t.Errorf("got %q, want %q", tok.Value, idTok) + } } - if tok.Value != idTok { - t.Fatalf("got %q, want %q", tok.Value, idTok) + if got, _ := creds.UniverseDomain(ctx); got != tt.wantUniverseDomain { + t.Errorf("got %q, want %q", got, tt.wantUniverseDomain) } }) } diff --git a/auth/credentials/impersonate/impersonate.go b/auth/credentials/impersonate/impersonate.go index 715b6b569d88..7d8efd54efb5 100644 --- a/auth/credentials/impersonate/impersonate.go +++ b/auth/credentials/impersonate/impersonate.go @@ -34,7 +34,6 @@ import ( var ( universeDomainPlaceholder = "UNIVERSE_DOMAIN" - iamCredentialsEndpoint = "https://iamcredentials.googleapis.com" iamCredentialsUniverseDomainEndpoint = "https://iamcredentials.UNIVERSE_DOMAIN" oauth2Endpoint = "https://oauth2.googleapis.com" errMissingTargetPrincipal = errors.New("impersonate: target service account must be provided") @@ -109,7 +108,7 @@ func NewCredentials(opts *CredentialsOptions) (*auth.Credentials, error) { logger: logger, } for _, v := range opts.Delegates { - its.delegates = append(its.delegates, formatIAMServiceAccountName(v)) + its.delegates = append(its.delegates, internal.FormatIAMServiceAccountResource(v)) } its.scopes = make([]string, len(opts.Scopes)) copy(its.scopes, opts.Scopes) @@ -215,10 +214,6 @@ func (o *CredentialsOptions) validate() error { return nil } -func formatIAMServiceAccountName(name string) string { - return fmt.Sprintf("projects/-/serviceAccounts/%s", name) -} - type generateAccessTokenRequest struct { Delegates []string `json:"delegates,omitempty"` Lifetime string `json:"lifetime,omitempty"` @@ -231,7 +226,8 @@ type generateAccessTokenResponse struct { } type impersonatedTokenProvider struct { - client *http.Client + client *http.Client + // universeDomain is used for endpoint construction. universeDomainProvider auth.CredentialsPropertyProvider logger *slog.Logger @@ -257,7 +253,7 @@ func (i impersonatedTokenProvider) Token(ctx context.Context) (*auth.Token, erro return nil, err } endpoint := strings.Replace(iamCredentialsUniverseDomainEndpoint, universeDomainPlaceholder, universeDomain, 1) - url := fmt.Sprintf("%s/v1/%s:generateAccessToken", endpoint, formatIAMServiceAccountName(i.targetPrincipal)) + url := fmt.Sprintf("%s/v1/%s:generateAccessToken", endpoint, internal.FormatIAMServiceAccountResource(i.targetPrincipal)) req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(b)) if err != nil { return nil, fmt.Errorf("impersonate: unable to create request: %w", err) diff --git a/auth/credentials/impersonate/impersonate_test.go b/auth/credentials/impersonate/impersonate_test.go index df9cade64ab6..f58abb7b8338 100644 --- a/auth/credentials/impersonate/impersonate_test.go +++ b/auth/credentials/impersonate/impersonate_test.go @@ -105,9 +105,6 @@ func TestNewCredentials_serviceAccount(t *testing.T) { saTok := "sa-token" client := &http.Client{ Transport: RoundTripFn(func(req *http.Request) *http.Response { - if !strings.Contains(req.URL.Path, "generateAccessToken") { - t.Fatal("path must contain 'generateAccessToken'") - } defer req.Body.Close() b, err := io.ReadAll(req.Body) if err != nil { @@ -126,6 +123,9 @@ func TestNewCredentials_serviceAccount(t *testing.T) { if !strings.Contains(req.URL.Hostname(), tt.wantUniverseDomain) { t.Errorf("got %q, want %q", req.URL.Hostname(), tt.wantUniverseDomain) } + if !strings.Contains(req.URL.Path, "generateAccessToken") { + t.Fatal("path must contain 'generateAccessToken'") + } resp := generateAccessTokenResponse{ AccessToken: saTok, diff --git a/auth/credentials/impersonate/user.go b/auth/credentials/impersonate/user.go index be21d220768e..e5e1d650284b 100644 --- a/auth/credentials/impersonate/user.go +++ b/auth/credentials/impersonate/user.go @@ -31,6 +31,10 @@ import ( "github.com/googleapis/gax-go/v2/internallog" ) +var ( + iamCredentialsEndpoint = "https://iamcredentials.googleapis.com" +) + // user provides an auth flow for domain-wide delegation, setting // CredentialsConfig.Subject to be the impersonated user. func user(opts *CredentialsOptions, client *http.Client, lifetime time.Duration, isStaticToken bool, universeDomainProvider auth.CredentialsPropertyProvider) (auth.TokenProvider, error) { @@ -47,7 +51,7 @@ func user(opts *CredentialsOptions, client *http.Client, lifetime time.Duration, } u.delegates = make([]string, len(opts.Delegates)) for i, v := range opts.Delegates { - u.delegates[i] = formatIAMServiceAccountName(v) + u.delegates[i] = internal.FormatIAMServiceAccountResource(v) } u.scopes = make([]string, len(opts.Scopes)) copy(u.scopes, opts.Scopes) @@ -143,7 +147,7 @@ func (u userTokenProvider) signJWT(ctx context.Context) (string, error) { if err != nil { return "", fmt.Errorf("impersonate: unable to marshal request: %w", err) } - reqURL := fmt.Sprintf("%s/v1/%s:signJwt", iamCredentialsEndpoint, formatIAMServiceAccountName(u.targetPrincipal)) + reqURL := fmt.Sprintf("%s/v1/%s:signJwt", iamCredentialsEndpoint, internal.FormatIAMServiceAccountResource(u.targetPrincipal)) req, err := http.NewRequestWithContext(ctx, "POST", reqURL, bytes.NewReader(bodyBytes)) if err != nil { return "", fmt.Errorf("impersonate: unable to create request: %w", err) diff --git a/auth/credentials/internal/impersonate/idtoken.go b/auth/credentials/internal/impersonate/idtoken.go new file mode 100644 index 000000000000..864b02cf104c --- /dev/null +++ b/auth/credentials/internal/impersonate/idtoken.go @@ -0,0 +1,98 @@ +// Copyright 2025 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 impersonate + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "strings" + "time" + + "cloud.google.com/go/auth" + "cloud.google.com/go/auth/internal" + "github.com/googleapis/gax-go/v2/internallog" +) + +var ( + universeDomainPlaceholder = "UNIVERSE_DOMAIN" + iamCredentialsUniverseDomainEndpoint = "https://iamcredentials.UNIVERSE_DOMAIN" +) + +// IDTokenIAMOptions provides configuration for [IDTokenIAMOptions.Token]. +type IDTokenIAMOptions struct { + // Client is required. + Client *http.Client + // Logger is required. + Logger *slog.Logger + UniverseDomain auth.CredentialsPropertyProvider + ServiceAccountEmail string + GenerateIDTokenRequest +} + +// GenerateIDTokenRequest holds the request to the IAM generateIdToken RPC. +type GenerateIDTokenRequest struct { + Audience string `json:"audience"` + IncludeEmail bool `json:"includeEmail"` + Delegates []string `json:"delegates,omitempty"` +} + +// GenerateIDTokenResponse holds the response from the IAM generateIdToken RPC. +type GenerateIDTokenResponse struct { + Token string `json:"token"` +} + +// Token call IAM generateIdToken with the configuration provided in [IDTokenIAMOptions]. +func (o IDTokenIAMOptions) Token(ctx context.Context) (*auth.Token, error) { + universeDomain, err := o.UniverseDomain.GetProperty(ctx) + if err != nil { + return nil, err + } + endpoint := strings.Replace(iamCredentialsUniverseDomainEndpoint, universeDomainPlaceholder, universeDomain, 1) + url := fmt.Sprintf("%s/v1/%s:generateIdToken", endpoint, internal.FormatIAMServiceAccountResource(o.ServiceAccountEmail)) + + bodyBytes, err := json.Marshal(o.GenerateIDTokenRequest) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + o.Logger.DebugContext(ctx, "impersonated idtoken request", "request", internallog.HTTPRequest(req, bodyBytes)) + resp, body, err := internal.DoRequest(o.Client, req) + if err != nil { + return nil, fmt.Errorf("impersonate: unable to generate ID token: %w", err) + } + o.Logger.DebugContext(ctx, "impersonated idtoken response", "response", internallog.HTTPResponse(resp, body)) + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("impersonate: status code %d: %s", c, body) + } + + var tokenResp GenerateIDTokenResponse + if err := json.Unmarshal(body, &tokenResp); err != nil { + return nil, fmt.Errorf("impersonate: unable to parse response: %w", err) + } + return &auth.Token{ + Value: tokenResp.Token, + // Generated ID tokens are good for one hour. + Expiry: time.Now().Add(1 * time.Hour), + }, nil +} diff --git a/auth/internal/internal.go b/auth/internal/internal.go index 6f4ef43bba33..6a8eab6eb99f 100644 --- a/auth/internal/internal.go +++ b/auth/internal/internal.go @@ -217,3 +217,9 @@ func getMetadataUniverseDomain(ctx context.Context, client *metadata.Client) (st } return "", err } + +// FormatIAMServiceAccountResource sets a service account name in an IAM resource +// name. +func FormatIAMServiceAccountResource(name string) string { + return fmt.Sprintf("projects/-/serviceAccounts/%s", name) +} diff --git a/go.work.sum b/go.work.sum index 69c20ee9946c..eeaf465ad29b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -5,9 +5,6 @@ cloud.google.com/go/gaming v1.9.0 h1:7vEhFnZmd931Mo7sZ6pJy7uQPDxF7m7v8xtBheG08tc github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.18.0 h1:ugYJK/neZQtQeh2jc5xNoDFiMQojlAkoqJMRb7vTu1U= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.18.0/go.mod h1:Xx0VKh7GJ4si3rmElbh19Mejxz68ibWg/J30ZOMrqzU= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.23.0/go.mod h1:p2puVVSKjQ84Qb1gzw2XHLs34WQyHTYFZLaVxypAFYs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.0/go.mod h1:p2puVVSKjQ84Qb1gzw2XHLs34WQyHTYFZLaVxypAFYs= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0 h1:3c8yed4lgqTt+oTQ+JNMDo+F4xprBf+O/il4ZC0nRLw= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.25.0/go.mod h1:obipzmGjfSjam60XLwGfqUkJsfiheAl+TUjG+4yzyPM= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ= github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM= @@ -25,6 +22,7 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.16.12/go.mod h1:b53qpmhHk7mTL2J/tfG6 github.com/aws/smithy-go v1.12.1/go.mod h1:Tg+OJXh4MB2R/uN61Ko2f6hTZwB/ZYGOtib8J3gBHzA= github.com/bazelbuild/rules_go v0.49.0/go.mod h1:Dhcz716Kqg1RHNWos+N6MlXNkjNP2EwZQ0LukRKJfMs= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= @@ -63,7 +61,6 @@ github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVs github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= go.opentelemetry.io/contrib/detectors/gcp v1.27.0/go.mod h1:amd+4uZxqJAUx7zI1JvygUtAc2EVWtQeyz8D+3161SQ= -go.opentelemetry.io/contrib/detectors/gcp v1.28.0/go.mod h1:9BIqH22qyHWAiZxQh0whuJygro59z+nbMVuc7ciiGug= go.opentelemetry.io/otel v1.23.1/go.mod h1:Td0134eafDLcTS4y+zQ26GE8u3dEuRBiBCTUIRHaikA= go.opentelemetry.io/otel/bridge/opencensus v0.40.0 h1:pqDiayRhBgoqy1vwnscik+TizcImJ58l053NScJyZso= go.opentelemetry.io/otel/bridge/opencensus v0.40.0/go.mod h1:1NvVHb6tLTe5A9qCYz+eErW0t8iPn4ZfR6tDKcqlGTM= @@ -73,12 +70,12 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v0.44.0/go.mod h1:sTt30Ev go.opentelemetry.io/otel/metric v1.23.1/go.mod h1:mpG2QPlAfnK8yNhNJAxDZruU9Y1/HubbC+KyH8FaCWI= go.opentelemetry.io/otel/sdk v1.27.0/go.mod h1:Ha9vbLwJE6W86YstIywK2xFfPjbWlCuwPtMkKdz/Y4A= go.opentelemetry.io/otel/sdk/metric v1.27.0/go.mod h1:we7jJVrYN2kh3mVBlswtPU22K0SA+769l93J6bsyvqw= -go.opentelemetry.io/otel/sdk/metric v1.28.0/go.mod h1:cWPjykihLAPvXKi4iZc1dpER3Jdq2Z0YLse3moQUCpg= go.opentelemetry.io/otel/trace v1.23.1/go.mod h1:4IpnpJFwr1mo/6HL8XIPJaE9y0+u1KcVmuW7dwFSVrI= golang.org/x/mod v0.9.0 h1:KENHtAZL2y3NLMYZeHY9DW8HW8V+kQyJsY/V9JlKvCs= golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU= golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/telemetry v0.0.0-20240208230135-b75ee8823808/go.mod h1:KG1lNk5ZFNssSZLrpVb4sMXKMpGwGXOxSG3rnu2gZQQ= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= @@ -87,6 +84,7 @@ golang.org/x/tools v0.7.0 h1:W4OVu8VVOaIO0yzWMNdepAulS7YfoS3Zabrm8DOXXU4= golang.org/x/tools v0.10.0 h1:tvDr/iQoUqNdohiYm0LmmKcBk+q86lb9EprIUFhHHGg= golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +golang.org/x/tools v0.23.0/go.mod h1:pnu6ufv6vQkll6szChhK3C3L/ruaIv5eBeztNG8wtsI= google.golang.org/api v0.174.0/go.mod h1:aC7tB6j0HR1Nl0ni5ghpx6iLasmAX78Zkh/wgxAAjLg= google.golang.org/genproto v0.0.0-20230725213213-b022f6e96895/go.mod h1:0ggbjUrZYpy1q+ANUS30SEoGZ53cdfwtbuG7Ptgy108= google.golang.org/genproto v0.0.0-20230731193218-e0aa005b6bdf/go.mod h1:oH/ZOT02u4kWEp7oYBGYFFkCdKS/uYR9Z7+0/xuuFp8=