1
2
3
4
5 package impersonate
6
7 import (
8 "bytes"
9 "context"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "net/http"
15 "time"
16
17 "golang.org/x/oauth2"
18 "google.golang.org/api/internal"
19 "google.golang.org/api/option"
20 "google.golang.org/api/option/internaloption"
21 htransport "google.golang.org/api/transport/http"
22 )
23
24 var (
25 iamCredentailsEndpoint = "https://iamcredentials.googleapis.com"
26 oauth2Endpoint = "https://oauth2.googleapis.com"
27 errMissingTargetPrincipal = errors.New("impersonate: a target service account must be provided")
28 errMissingScopes = errors.New("impersonate: scopes must be provided")
29 errLifetimeOverMax = errors.New("impersonate: max lifetime is 12 hours")
30 errUniverseNotSupportedDomainWideDelegation = errors.New("impersonate: service account user is configured for the credential. " +
31 "Domain-wide delegation is not supported in universes other than googleapis.com")
32 )
33
34
35 type CredentialsConfig struct {
36
37
38 TargetPrincipal string
39
40 Scopes []string
41
42
43
44 Delegates []string
45
46
47
48
49
50
51 Lifetime time.Duration
52
53
54
55 Subject string
56 }
57
58
59
60 func defaultClientOptions() []option.ClientOption {
61 return []option.ClientOption{
62 internaloption.WithDefaultAudience("https://iamcredentials.googleapis.com/"),
63 internaloption.WithDefaultScopes("https://www.googleapis.com/auth/cloud-platform"),
64 }
65 }
66
67
68
69
70 func CredentialsTokenSource(ctx context.Context, config CredentialsConfig, opts ...option.ClientOption) (oauth2.TokenSource, error) {
71 if config.TargetPrincipal == "" {
72 return nil, errMissingTargetPrincipal
73 }
74 if len(config.Scopes) == 0 {
75 return nil, errMissingScopes
76 }
77 if config.Lifetime.Hours() > 12 {
78 return nil, errLifetimeOverMax
79 }
80
81 var isStaticToken bool
82
83
84 lifetime := 3600 * time.Second
85 if config.Lifetime != 0 {
86 lifetime = config.Lifetime
87
88 isStaticToken = true
89 }
90
91 clientOpts := append(defaultClientOptions(), opts...)
92 client, _, err := htransport.NewClient(ctx, clientOpts...)
93 if err != nil {
94 return nil, err
95 }
96
97
98 if config.Subject != "" {
99 settings, err := newSettings(clientOpts)
100 if err != nil {
101 return nil, err
102 }
103 if !settings.IsUniverseDomainGDU() {
104 return nil, errUniverseNotSupportedDomainWideDelegation
105 }
106 return user(ctx, config, client, lifetime, isStaticToken)
107 }
108
109 its := impersonatedTokenSource{
110 client: client,
111 targetPrincipal: config.TargetPrincipal,
112 lifetime: fmt.Sprintf("%.fs", lifetime.Seconds()),
113 }
114 for _, v := range config.Delegates {
115 its.delegates = append(its.delegates, formatIAMServiceAccountName(v))
116 }
117 its.scopes = make([]string, len(config.Scopes))
118 copy(its.scopes, config.Scopes)
119
120 if isStaticToken {
121 tok, err := its.Token()
122 if err != nil {
123 return nil, err
124 }
125 return oauth2.StaticTokenSource(tok), nil
126 }
127 return oauth2.ReuseTokenSource(nil, its), nil
128 }
129
130 func newSettings(opts []option.ClientOption) (*internal.DialSettings, error) {
131 var o internal.DialSettings
132 for _, opt := range opts {
133 opt.Apply(&o)
134 }
135 if err := o.Validate(); err != nil {
136 return nil, err
137 }
138
139 return &o, nil
140 }
141
142 func formatIAMServiceAccountName(name string) string {
143 return fmt.Sprintf("projects/-/serviceAccounts/%s", name)
144 }
145
146 type generateAccessTokenReq struct {
147 Delegates []string `json:"delegates,omitempty"`
148 Lifetime string `json:"lifetime,omitempty"`
149 Scope []string `json:"scope,omitempty"`
150 }
151
152 type generateAccessTokenResp struct {
153 AccessToken string `json:"accessToken"`
154 ExpireTime string `json:"expireTime"`
155 }
156
157 type impersonatedTokenSource struct {
158 client *http.Client
159
160 targetPrincipal string
161 lifetime string
162 scopes []string
163 delegates []string
164 }
165
166
167 func (i impersonatedTokenSource) Token() (*oauth2.Token, error) {
168 reqBody := generateAccessTokenReq{
169 Delegates: i.delegates,
170 Lifetime: i.lifetime,
171 Scope: i.scopes,
172 }
173 b, err := json.Marshal(reqBody)
174 if err != nil {
175 return nil, fmt.Errorf("impersonate: unable to marshal request: %v", err)
176 }
177 url := fmt.Sprintf("%s/v1/%s:generateAccessToken", iamCredentailsEndpoint, formatIAMServiceAccountName(i.targetPrincipal))
178 req, err := http.NewRequest("POST", url, bytes.NewReader(b))
179 if err != nil {
180 return nil, fmt.Errorf("impersonate: unable to create request: %v", err)
181 }
182 req.Header.Set("Content-Type", "application/json")
183
184 resp, err := i.client.Do(req)
185 if err != nil {
186 return nil, fmt.Errorf("impersonate: unable to generate access token: %v", err)
187 }
188 defer resp.Body.Close()
189 body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
190 if err != nil {
191 return nil, fmt.Errorf("impersonate: unable to read body: %v", err)
192 }
193 if c := resp.StatusCode; c < 200 || c > 299 {
194 return nil, fmt.Errorf("impersonate: status code %d: %s", c, body)
195 }
196
197 var accessTokenResp generateAccessTokenResp
198 if err := json.Unmarshal(body, &accessTokenResp); err != nil {
199 return nil, fmt.Errorf("impersonate: unable to parse response: %v", err)
200 }
201 expiry, err := time.Parse(time.RFC3339, accessTokenResp.ExpireTime)
202 if err != nil {
203 return nil, fmt.Errorf("impersonate: unable to parse expiry: %v", err)
204 }
205 return &oauth2.Token{
206 AccessToken: accessTokenResp.AccessToken,
207 Expiry: expiry,
208 }, nil
209 }
210
View as plain text