Skip to content

Commit

Permalink
feat: allow impersonated_service_account credential type to generate …
Browse files Browse the repository at this point in the history
…identity token on the current impersonated service account
  • Loading branch information
guillaumeblaquiere committed Jan 21, 2022
1 parent 2560eda commit fda4192
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 28 deletions.
2 changes: 1 addition & 1 deletion go.mod
Expand Up @@ -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
Expand Down
61 changes: 34 additions & 27 deletions idtoken/idtoken.go
Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand All @@ -97,46 +96,54 @@ 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)

This comment has been minimized.

Copy link
@j0hnsmith

j0hnsmith Jun 13, 2022

credential must be service_account -> credential must be service_account or impersonated_service_account


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
}
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.
Expand Down
128 changes: 128 additions & 0 deletions 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
}

0 comments on commit fda4192

Please sign in to comment.