1 // Copyright 2023 Google LLC
2 //
3 // Licensed under the Apache License, Version 2.0 (the "License");
4 // you may not use this file except in compliance with the License.
5 // You may obtain a copy of the License at
6 //
7 // http://www.apache.org/licenses/LICENSE-2.0
8 //
9 // Unless required by applicable law or agreed to in writing, software
10 // distributed under the License is distributed on an "AS IS" BASIS,
11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 // See the License for the specific language governing permissions and
13 // limitations under the License.
14 15 package impersonate
16 17 import (
18 "bytes"
19 "context"
20 "encoding/json"
21 "errors"
22 "fmt"
23 "log/slog"
24 "net/http"
25 "regexp"
26 "time"
27 28 "cloud.google.com/go/auth"
29 "cloud.google.com/go/auth/internal"
30 "cloud.google.com/go/auth/internal/transport/headers"
31 "github.com/googleapis/gax-go/v2/internallog"
32 )
33 34 const (
35 defaultTokenLifetime = "3600s"
36 authHeaderKey = "Authorization"
37 )
38 39 var serviceAccountEmailRegex = regexp.MustCompile(`serviceAccounts/(.+?):generateAccessToken`)
40 41 // generateAccesstokenReq is used for service account impersonation
42 type generateAccessTokenReq struct {
43 Delegates []string `json:"delegates,omitempty"`
44 Lifetime string `json:"lifetime,omitempty"`
45 Scope []string `json:"scope,omitempty"`
46 }
47 48 type impersonateTokenResponse struct {
49 AccessToken string `json:"accessToken"`
50 ExpireTime string `json:"expireTime"`
51 }
52 53 // NewTokenProvider uses a source credential, stored in Ts, to request an access token to the provided URL.
54 // Scopes can be defined when the access token is requested.
55 func NewTokenProvider(opts *Options) (auth.TokenProvider, error) {
56 if err := opts.validate(); err != nil {
57 return nil, err
58 }
59 return opts, nil
60 }
61 62 // Options for [NewTokenProvider].
63 type Options struct {
64 // Tp is the source credential used to generate a token on the
65 // impersonated service account. Required.
66 Tp auth.TokenProvider
67 68 // URL is the endpoint to call to generate a token
69 // on behalf of the service account. Required.
70 URL string
71 // Scopes that the impersonated credential should have. Required.
72 Scopes []string
73 // Delegates are the service account email addresses in a delegation chain.
74 // Each service account must be granted roles/iam.serviceAccountTokenCreator
75 // on the next service account in the chain. Optional.
76 Delegates []string
77 // TokenLifetimeSeconds is the number of seconds the impersonation token will
78 // be valid for. Defaults to 1 hour if unset. Optional.
79 TokenLifetimeSeconds int
80 // Client configures the underlying client used to make network requests
81 // when fetching tokens. Required.
82 Client *http.Client
83 // Logger is used for debug logging. If provided, logging will be enabled
84 // at the loggers configured level. By default logging is disabled unless
85 // enabled by setting GOOGLE_SDK_GO_LOGGING_LEVEL in which case a default
86 // logger will be used. Optional.
87 Logger *slog.Logger
88 // UniverseDomain is the default service domain for a given Cloud universe.
89 UniverseDomain string
90 }
91 92 func (o *Options) validate() error {
93 if o.Tp == nil {
94 return errors.New("credentials: missing required 'source_credentials' field in impersonated credentials")
95 }
96 if o.URL == "" {
97 return errors.New("credentials: missing required 'service_account_impersonation_url' field in impersonated credentials")
98 }
99 return nil
100 }
101 102 // Token performs the exchange to get a temporary service account token to allow access to GCP.
103 func (o *Options) Token(ctx context.Context) (*auth.Token, error) {
104 logger := internallog.New(o.Logger)
105 lifetime := defaultTokenLifetime
106 if o.TokenLifetimeSeconds != 0 {
107 lifetime = fmt.Sprintf("%ds", o.TokenLifetimeSeconds)
108 }
109 reqBody := generateAccessTokenReq{
110 Lifetime: lifetime,
111 Scope: o.Scopes,
112 Delegates: o.Delegates,
113 }
114 b, err := json.Marshal(reqBody)
115 if err != nil {
116 return nil, fmt.Errorf("credentials: unable to marshal request: %w", err)
117 }
118 req, err := http.NewRequestWithContext(ctx, "POST", o.URL, bytes.NewReader(b))
119 if err != nil {
120 return nil, fmt.Errorf("credentials: unable to create impersonation request: %w", err)
121 }
122 req.Header.Set("Content-Type", "application/json")
123 sourceToken, err := o.Tp.Token(ctx)
124 if err != nil {
125 return nil, err
126 }
127 headers.SetAuthHeader(sourceToken, req)
128 logger.DebugContext(ctx, "impersonated token request", "request", internallog.HTTPRequest(req, b))
129 resp, body, err := internal.DoRequest(o.Client, req)
130 if err != nil {
131 return nil, fmt.Errorf("credentials: unable to generate access token: %w", err)
132 }
133 logger.DebugContext(ctx, "impersonated token response", "response", internallog.HTTPResponse(resp, body))
134 if c := resp.StatusCode; c < http.StatusOK || c >= http.StatusMultipleChoices {
135 return nil, fmt.Errorf("credentials: status code %d: %s", c, body)
136 }
137 138 var accessTokenResp impersonateTokenResponse
139 if err := json.Unmarshal(body, &accessTokenResp); err != nil {
140 return nil, fmt.Errorf("credentials: unable to parse response: %w", err)
141 }
142 expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
143 if err != nil {
144 return nil, fmt.Errorf("credentials: unable to parse expiry: %w", err)
145 }
146 token := &auth.Token{
147 Value: accessTokenResp.AccessToken,
148 Expiry: expiry,
149 Type: internal.TokenTypeBearer,
150 }
151 return token, nil
152 }
153 154 // ExtractServiceAccountEmail extracts the service account email from the impersonation URL.
155 // The impersonation URL is expected to be in the format:
156 // https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{SERVICE_ACCOUNT_EMAIL}:generateAccessToken
157 // or
158 // https://iamcredentials.googleapis.com/v1/projects/{PROJECT_ID}/serviceAccounts/{SERVICE_ACCOUNT_EMAIL}:generateAccessToken
159 // Returns an error if the email cannot be extracted.
160 func ExtractServiceAccountEmail(impersonationURL string) (string, error) {
161 matches := serviceAccountEmailRegex.FindStringSubmatch(impersonationURL)
162 163 if len(matches) < 2 {
164 return "", fmt.Errorf("credentials: invalid impersonation URL format: %s", impersonationURL)
165 }
166 167 return matches[1], nil
168 }
169