From fda41924968f867584333c4151c841e14dab4dae Mon Sep 17 00:00:00 2001 From: Guillaume Blaquiere Date: Fri, 21 Jan 2022 20:55:37 +0100 Subject: [PATCH] feat: allow impersonated_service_account credential type to generate identity token on the current impersonated service account --- go.mod | 2 +- idtoken/idtoken.go | 61 ++++++------ idtoken/impersonatedServiceAccount.go | 128 ++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 28 deletions(-) create mode 100644 idtoken/impersonatedServiceAccount.go diff --git a/go.mod b/go.mod index 2b8dc3dc1a2..48ecb47b6d7 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/googleapis/gax-go/v2 v2.0.5 go.opencensus.io v0.22.4 golang.org/x/lint v0.0.0-20200302205851-738671d3881b - golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d + golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8 //TODO update after the no-scope fix (pull request #538) golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208 golang.org/x/sys v0.0.0-20200817085935-3ff754bf58a9 golang.org/x/tools v0.0.0-20200817023811-d00afeaade8f diff --git a/idtoken/idtoken.go b/idtoken/idtoken.go index 67c89656a02..ea7ed20f779 100644 --- a/idtoken/idtoken.go +++ b/idtoken/idtoken.go @@ -13,7 +13,6 @@ import ( "cloud.google.com/go/compute/metadata" "golang.org/x/oauth2" "golang.org/x/oauth2/google" - "google.golang.org/api/internal" "google.golang.org/api/option" "google.golang.org/api/option/internaloption" @@ -87,7 +86,7 @@ func newTokenSource(ctx context.Context, audience string, ds *internal.DialSetti return nil, err } if len(creds.JSON) > 0 { - return tokenSourceFromBytes(ctx, creds.JSON, audience, ds) + return tokenSourceFromBytes(ctx, creds, audience, ds) } // If internal.Creds did not return a response with JSON fallback to the // metadata service as the creds.TokenSource is not an ID token. @@ -97,25 +96,36 @@ func newTokenSource(ctx context.Context, audience string, ds *internal.DialSetti return nil, fmt.Errorf("idtoken: couldn't find any credentials") } -func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds *internal.DialSettings) (oauth2.TokenSource, error) { - if err := isServiceAccount(data); err != nil { - return nil, err - } - cfg, err := google.JWTConfigFromJSON(data, ds.Scopes...) - if err != nil { - return nil, err - } +func tokenSourceFromBytes(ctx context.Context, creds *google.Credentials, audience string, ds *internal.DialSettings) (oauth2.TokenSource, error) { + data := creds.JSON + jsonType, err := getJsonType(data) + + var ts oauth2.TokenSource + switch jsonType { + case "service_account": + cfg, err := google.JWTConfigFromJSON(data, ds.Scopes...) + if err != nil { + return nil, err + } + + customClaims := ds.CustomClaims + if customClaims == nil { + customClaims = make(map[string]interface{}) + } + customClaims["target_audience"] = audience + + cfg.PrivateClaims = customClaims + cfg.UseIDToken = true + ts = cfg.TokenSource(ctx) + case "impersonated_service_account": + ts, err = impersonateServiceAccountTokenSource(audience, ds, creds) + if err != nil { + return nil, err + } + default: + return nil, fmt.Errorf("idtoken: credential must be service_account, found %q", jsonType) - customClaims := ds.CustomClaims - if customClaims == nil { - customClaims = make(map[string]interface{}) } - customClaims["target_audience"] = audience - - cfg.PrivateClaims = customClaims - cfg.UseIDToken = true - - ts := cfg.TokenSource(ctx) tok, err := ts.Token() if err != nil { return nil, err @@ -123,20 +133,17 @@ func tokenSourceFromBytes(ctx context.Context, data []byte, audience string, ds return oauth2.ReuseTokenSource(tok, ts), nil } -func isServiceAccount(data []byte) error { +func getJsonType(data []byte) (jsonType string, err error) { if len(data) == 0 { - return fmt.Errorf("idtoken: credential provided is 0 bytes") + return "", fmt.Errorf("idtoken: credential provided is 0 bytes") } var f struct { Type string `json:"type"` } - if err := json.Unmarshal(data, &f); err != nil { - return err - } - if f.Type != "service_account" { - return fmt.Errorf("idtoken: credential must be service_account, found %q", f.Type) + if err = json.Unmarshal(data, &f); err != nil { + return "", err } - return nil + return f.Type, nil } // WithCustomClaims optionally specifies custom private claims for an ID token. diff --git a/idtoken/impersonatedServiceAccount.go b/idtoken/impersonatedServiceAccount.go new file mode 100644 index 00000000000..42221597aee --- /dev/null +++ b/idtoken/impersonatedServiceAccount.go @@ -0,0 +1,128 @@ +// Copyright 2020 Google LLC. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package idtoken + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "golang.org/x/oauth2/google" + "io/ioutil" + "net/http" + "regexp" + "strings" + "time" + + "golang.org/x/oauth2" + + "google.golang.org/api/internal" +) + +var impersonateUrlFormat = regexp.MustCompile(`https:\/\/iamcredentials.googleapis.com\/v1\/projects\/-\/serviceAccounts\/\S+@\S+\.iam\.gserviceaccount\.com:generateAccessToken`) + +// impersonateServiceAccountTokenSource uses impersonated service account credential type. It will +// use the Service Account Credential API and the generateIdToken method to fetch identity token on +// the current impersonated service account. +func impersonateServiceAccountTokenSource(audience string, ds *internal.DialSettings, creds *google.Credentials) (oauth2.TokenSource, error) { + if ds.CustomClaims != nil { + return nil, fmt.Errorf("idtoken: WithCustomClaims can't be used with impersonated credentials, please provide a service account if you would like to use this feature") + } + var f struct { + ImpersonationUrl string `json:"service_account_impersonation_url"` + Delegates []string `json:"delegates"` + } + if err := json.Unmarshal(creds.JSON, &f); err != nil { + return nil, err + } + + // Check URL value and format + if f.ImpersonationUrl == "" { + return nil, fmt.Errorf("idtoken: service_account_impersonation_url can't be empty for credential type impersonated_service_account") + } + if !impersonateUrlFormat.Match([]byte(f.ImpersonationUrl)) { + return nil, fmt.Errorf("idtoken: service_account_impersonation_url doesn't have the correct pattern") + } + + serviceAccountEmail := f.ImpersonationUrl[strings.LastIndex(f.ImpersonationUrl, "/")+1 : strings.LastIndex(f.ImpersonationUrl, ":")] + idTokenUrl := strings.Replace(f.ImpersonationUrl, "generateAccessToken", "generateIdToken", 1) + + ts := impersonateServiceAccount{ + audience: audience, + email: serviceAccountEmail, + idTokenUrl: idTokenUrl, + delegates: f.Delegates, + // Reuse current impersonate service account definition to generate an access token on a + // standard google OAuth2 HTTP client + client: oauth2.NewClient(context.Background(), creds.TokenSource), + } + tok, err := ts.Token() + if err != nil { + return nil, err + } + return oauth2.ReuseTokenSource(tok, ts), nil +} + +// impersonateServiceAccount represents the minimal data to request an identity token to the +// Service Account Credentials API. +type impersonateServiceAccount struct { + audience string + email string + idTokenUrl string + delegates []string + client *http.Client +} + +// requestGenerateIdToken represents the body to submit to Service Account Credentials API +// to generate an identity token +type requestGenerateIdToken struct { + Delegates []string `json:"delegates"` + Audience string `json:"audience"` + IncludeEmail bool `json:"includeEmail"` +} + +func (c impersonateServiceAccount) Token() (*oauth2.Token, error) { + request := requestGenerateIdToken{ + Delegates: c.delegates, + Audience: c.audience, + IncludeEmail: true, + } + + reqBody, _ := json.Marshal(request) + + resp, err := c.client.Post(c.idTokenUrl, "application/json", bytes.NewBuffer(reqBody)) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, errors.New(fmt.Sprintf("idToken: invalid status code %s", resp.Status)) + } + + body, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var f struct { + Token string `json:"token"` + } + + if err = json.Unmarshal(body, &f); err != nil { + return nil, err + } + + if f.Token == "" { + return nil, fmt.Errorf("idtoken: invalid response from Service Account Credential API") + } + return &oauth2.Token{ + AccessToken: f.Token, + TokenType: "bearer", + // Tokens are valid for one hour, leave a little buffer + Expiry: time.Now().Add(55 * time.Minute), + }, nil +}