From ee34736a47aef8bdf3dc4c91aeb2ffd5da9ef57d Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Tue, 29 Oct 2024 16:52:01 -0600 Subject: [PATCH] feat(auth): add universe domain support to idtoken --- auth/credentials/idtoken/file.go | 152 +++++++++++++++++--- auth/credentials/idtoken/idtoken.go | 5 + auth/credentials/impersonate/idtoken.go | 4 +- auth/credentials/impersonate/impersonate.go | 8 +- auth/credentials/impersonate/user.go | 4 +- auth/internal/internal.go | 6 + 6 files changed, 146 insertions(+), 33 deletions(-) diff --git a/auth/credentials/idtoken/file.go b/auth/credentials/idtoken/file.go index 333521c91940..26661e32102c 100644 --- a/auth/credentials/idtoken/file.go +++ b/auth/credentials/idtoken/file.go @@ -15,14 +15,19 @@ package idtoken import ( + "bytes" + "context" "encoding/json" "fmt" + "net/http" "path/filepath" "strings" + "time" "cloud.google.com/go/auth" "cloud.google.com/go/auth/credentials" "cloud.google.com/go/auth/credentials/impersonate" + "cloud.google.com/go/auth/httptransport" "cloud.google.com/go/auth/internal" "cloud.google.com/go/auth/internal/credsfile" ) @@ -37,6 +42,9 @@ var ( "https://iamcredentials.googleapis.com/", "https://www.googleapis.com/auth/cloud-platform", } + + universeDomainPlaceholder = "UNIVERSE_DOMAIN" + iamCredentialsUniverseDomainEndpoint = "https://iamcredentials.UNIVERSE_DOMAIN" ) func credsFromBytes(b []byte, opts *Options) (*auth.Credentials, error) { @@ -50,38 +58,23 @@ func credsFromBytes(b []byte, opts *Options) (*auth.Credentials, error) { if err != nil { return nil, err } - opts2LO := &auth.Options2LO{ - Email: f.ClientEmail, - PrivateKey: []byte(f.PrivateKey), - PrivateKeyID: f.PrivateKeyID, - TokenURL: f.TokenURL, - UseIDToken: true, - } - 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{}) + var tp auth.TokenProvider + if opts.UseIAMEndpoint { + tp, err = newIDTokenProvider(f, opts) + } else { + tp, err = new2LOTokenProvider(f, opts) } - customClaims["target_audience"] = opts.Audience - - opts2LO.PrivateClaims = customClaims - tp, err := auth.New2LOTokenProvider(opts2LO) if err != nil { return nil, err } tp = auth.NewCachedTokenProvider(tp, nil) - return auth.NewCredentials(&auth.CredentialsOptions{ + creds := auth.NewCredentials(&auth.CredentialsOptions{ TokenProvider: tp, JSON: b, ProjectIDProvider: internal.StaticCredentialsProperty(f.ProjectID), UniverseDomainProvider: internal.StaticCredentialsProperty(f.UniverseDomain), - }), nil + }) + return creds, nil case credsfile.ImpersonatedServiceAccountKey, credsfile.ExternalAccountKey: type url struct { ServiceAccountImpersonationURL string `json:"service_account_impersonation_url"` @@ -125,3 +118,116 @@ func credsFromBytes(b []byte, opts *Options) (*auth.Credentials, error) { 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, + } + 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) +} + +func newIDTokenProvider(f *credsfile.ServiceAccountFile, opts *Options) (auth.TokenProvider, error) { + var client *http.Client + var creds *auth.Credentials // TODO(quartzmo): create new Credentials and TokenProvider for self-signed JWT (but what to do if GDU and not self-signed?) + var err error + universeDomain := resolveUniverseDomain(f) + if opts.Client == nil { + client, err = httptransport.NewClient(&httptransport.Options{ + Credentials: creds, + UniverseDomain: universeDomain, + }) + if err != nil { + return nil, err + } + } else { + client = opts.Client + } + its := idTokenProvider{ + client: client, + universeDomain: universeDomain, + signerEmail: f.ClientEmail, + audience: opts.Audience, + } + return its, nil +} + +type generateIDTokenRequest struct { + Audience string `json:"audience"` + IncludeEmail bool `json:"includeEmail"` +} + +type generateIDTokenResponse struct { + Token string `json:"token"` +} + +type idTokenProvider struct { + client *http.Client + universeDomain string + // signerEmail is the service account client email used to form the IAM generateIdToken endpoint. + signerEmail string + audience string +} + +func (i idTokenProvider) Token(ctx context.Context) (*auth.Token, error) { + genIDTokenReq := generateIDTokenRequest{ + Audience: i.audience, + IncludeEmail: true, + } + bodyBytes, err := json.Marshal(genIDTokenReq) + if err != nil { + return nil, fmt.Errorf("idtoken: unable to marshal request: %w", err) + } + + endpoint := strings.Replace(iamCredentialsUniverseDomainEndpoint, universeDomainPlaceholder, i.universeDomain, 1) + url := fmt.Sprintf("%s/v1/%s:generateIdToken", endpoint, internal.FormatIAMServiceAccountName(i.signerEmail)) + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(bodyBytes)) + if err != nil { + return nil, fmt.Errorf("idtoken: unable to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, body, err := internal.DoRequest(i.client, req) + if err != nil { + return nil, fmt.Errorf("idtoken: unable to generate ID token: %w", err) + } + if c := resp.StatusCode; c < 200 || c > 299 { + return nil, fmt.Errorf("idtoken: status code %d: %s", c, body) + } + + var generateIDTokenResp generateIDTokenResponse + if err := json.Unmarshal(body, &generateIDTokenResp); err != nil { + return nil, fmt.Errorf("idtoken: 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 +} + +// 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 b66c6551e6ee..86e1a9ccaacc 100644 --- a/auth/credentials/idtoken/idtoken.go +++ b/auth/credentials/idtoken/idtoken.go @@ -74,6 +74,11 @@ type Options struct { // when fetching tokens. If provided this should be a fully authenticated // client. Optional. Client *http.Client + // UseIAMEndpoint configures whether the IAM generateIdToken endpoint will + // be used instead of the oauth2.googleapis.com/token endpoint. Note that + // the iam.serviceAccountTokenCreator role is required to use the IAM + // generateIdToken endpoint. The default value is false. Optional. + UseIAMEndpoint bool } func (o *Options) client() *http.Client { diff --git a/auth/credentials/impersonate/idtoken.go b/auth/credentials/impersonate/idtoken.go index e51bee7d8764..e8921ff1dff1 100644 --- a/auth/credentials/impersonate/idtoken.go +++ b/auth/credentials/impersonate/idtoken.go @@ -118,7 +118,7 @@ func NewIDTokenCredentials(opts *IDTokenOptions) (*auth.Credentials, error) { includeEmail: opts.IncludeEmail, } for _, v := range opts.Delegates { - itp.delegates = append(itp.delegates, formatIAMServiceAccountName(v)) + itp.delegates = append(itp.delegates, internal.FormatIAMServiceAccountName(v)) } var udp auth.CredentialsPropertyProvider @@ -161,7 +161,7 @@ func (i impersonatedIDTokenProvider) Token(ctx context.Context) (*auth.Token, er return nil, fmt.Errorf("impersonate: unable to marshal request: %w", err) } - url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentialsEndpoint, formatIAMServiceAccountName(i.targetPrincipal)) + url := fmt.Sprintf("%s/v1/%s:generateIdToken", iamCredentialsEndpoint, internal.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) diff --git a/auth/credentials/impersonate/impersonate.go b/auth/credentials/impersonate/impersonate.go index df306057b49c..eb2f5d1b809e 100644 --- a/auth/credentials/impersonate/impersonate.go +++ b/auth/credentials/impersonate/impersonate.go @@ -111,7 +111,7 @@ func NewCredentials(opts *CredentialsOptions) (*auth.Credentials, error) { universeDomainProvider: universeDomainProvider, } for _, v := range opts.Delegates { - its.delegates = append(its.delegates, formatIAMServiceAccountName(v)) + its.delegates = append(its.delegates, internal.FormatIAMServiceAccountName(v)) } its.scopes = make([]string, len(opts.Scopes)) copy(its.scopes, opts.Scopes) @@ -197,10 +197,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"` @@ -238,7 +234,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.FormatIAMServiceAccountName(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/user.go b/auth/credentials/impersonate/user.go index b5e5fc8f6645..73180222a5aa 100644 --- a/auth/credentials/impersonate/user.go +++ b/auth/credentials/impersonate/user.go @@ -44,7 +44,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.FormatIAMServiceAccountName(v) } u.scopes = make([]string, len(opts.Scopes)) copy(u.scopes, opts.Scopes) @@ -139,7 +139,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.FormatIAMServiceAccountName(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/internal/internal.go b/auth/internal/internal.go index d8c16119180e..8e610800fc3a 100644 --- a/auth/internal/internal.go +++ b/auth/internal/internal.go @@ -214,3 +214,9 @@ func getMetadataUniverseDomain(ctx context.Context) (string, error) { } return "", err } + +// FormatIAMServiceAccountName sets a service account name in an IAM resource +// name. +func FormatIAMServiceAccountName(name string) string { + return fmt.Sprintf("projects/-/serviceAccounts/%s", name) +}