Skip to content

Commit

Permalink
feat(auth): add universe domain support to idtoken
Browse files Browse the repository at this point in the history
  • Loading branch information
quartzmo committed Oct 29, 2024
1 parent 2a667c6 commit ee34736
Show file tree
Hide file tree
Showing 6 changed files with 146 additions and 33 deletions.
152 changes: 129 additions & 23 deletions auth/credentials/idtoken/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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) {
Expand All @@ -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"`
Expand Down Expand Up @@ -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
}
5 changes: 5 additions & 0 deletions auth/credentials/idtoken/idtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions auth/credentials/impersonate/idtoken.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 2 additions & 6 deletions auth/credentials/impersonate/impersonate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions auth/credentials/impersonate/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions auth/internal/internal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

0 comments on commit ee34736

Please sign in to comment.