-
Notifications
You must be signed in to change notification settings - Fork 4.4k
/
sts.go
400 lines (362 loc) · 14 KB
/
sts.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
/*
*
* Copyright 2020 gRPC authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
// Package sts implements call credentials using STS (Security Token Service) as
// defined in https://tools.ietf.org/html/rfc8693.
//
// # Experimental
//
// Notice: All APIs in this package are experimental and may be changed or
// removed in a later release.
package sts
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"sync"
"time"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/grpclog"
)
const (
// HTTP request timeout set on the http.Client used to make STS requests.
stsRequestTimeout = 5 * time.Second
// If lifetime left in a cached token is lesser than this value, we fetch a
// new one instead of returning the current one.
minCachedTokenLifetime = 300 * time.Second
tokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange"
defaultCloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
)
// For overriding in tests.
var (
loadSystemCertPool = x509.SystemCertPool
makeHTTPDoer = makeHTTPClient
readSubjectTokenFrom = os.ReadFile
readActorTokenFrom = os.ReadFile
logger = grpclog.Component("credentials")
)
// Options configures the parameters used for an STS based token exchange.
type Options struct {
// TokenExchangeServiceURI is the address of the server which implements STS
// token exchange functionality.
TokenExchangeServiceURI string // Required.
// Resource is a URI that indicates the target service or resource where the
// client intends to use the requested security token.
Resource string // Optional.
// Audience is the logical name of the target service where the client
// intends to use the requested security token
Audience string // Optional.
// Scope is a list of space-delimited, case-sensitive strings, that allow
// the client to specify the desired scope of the requested security token
// in the context of the service or resource where the token will be used.
// If this field is left unspecified, a default value of
// https://www.googleapis.com/auth/cloud-platform will be used.
Scope string // Optional.
// RequestedTokenType is an identifier, as described in
// https://tools.ietf.org/html/rfc8693#section-3, that indicates the type of
// the requested security token.
RequestedTokenType string // Optional.
// SubjectTokenPath is a filesystem path which contains the security token
// that represents the identity of the party on behalf of whom the request
// is being made.
SubjectTokenPath string // Required.
// SubjectTokenType is an identifier, as described in
// https://tools.ietf.org/html/rfc8693#section-3, that indicates the type of
// the security token in the "subject_token_path" parameter.
SubjectTokenType string // Required.
// ActorTokenPath is a security token that represents the identity of the
// acting party.
ActorTokenPath string // Optional.
// ActorTokenType is an identifier, as described in
// https://tools.ietf.org/html/rfc8693#section-3, that indicates the type of
// the security token in the "actor_token_path" parameter.
ActorTokenType string // Optional.
}
func (o Options) String() string {
return fmt.Sprintf("%s:%s:%s:%s:%s:%s:%s:%s:%s", o.TokenExchangeServiceURI, o.Resource, o.Audience, o.Scope, o.RequestedTokenType, o.SubjectTokenPath, o.SubjectTokenType, o.ActorTokenPath, o.ActorTokenType)
}
// NewCredentials returns a new PerRPCCredentials implementation, configured
// using opts, which performs token exchange using STS.
func NewCredentials(opts Options) (credentials.PerRPCCredentials, error) {
if err := validateOptions(opts); err != nil {
return nil, err
}
// Load the system roots to validate the certificate presented by the STS
// endpoint during the TLS handshake.
roots, err := loadSystemCertPool()
if err != nil {
return nil, err
}
return &callCreds{
opts: opts,
client: makeHTTPDoer(roots),
}, nil
}
// callCreds provides the implementation of call credentials based on an STS
// token exchange.
type callCreds struct {
opts Options
client httpDoer
// Cached accessToken to avoid an STS token exchange for every call to
// GetRequestMetadata.
mu sync.Mutex
tokenMetadata map[string]string
tokenExpiry time.Time
}
// GetRequestMetadata returns the cached accessToken, if available and valid, or
// fetches a new one by performing an STS token exchange.
func (c *callCreds) GetRequestMetadata(ctx context.Context, _ ...string) (map[string]string, error) {
ri, _ := credentials.RequestInfoFromContext(ctx)
if err := credentials.CheckSecurityLevel(ri.AuthInfo, credentials.PrivacyAndIntegrity); err != nil {
return nil, fmt.Errorf("unable to transfer STS PerRPCCredentials: %v", err)
}
// Holding the lock for the whole duration of the STS request and response
// processing ensures that concurrent RPCs don't end up in multiple
// requests being made.
c.mu.Lock()
defer c.mu.Unlock()
if md := c.cachedMetadata(); md != nil {
return md, nil
}
req, err := constructRequest(ctx, c.opts)
if err != nil {
return nil, err
}
respBody, err := sendRequest(c.client, req)
if err != nil {
return nil, err
}
ti, err := tokenInfoFromResponse(respBody)
if err != nil {
return nil, err
}
c.tokenMetadata = map[string]string{"Authorization": fmt.Sprintf("%s %s", ti.tokenType, ti.token)}
c.tokenExpiry = ti.expiryTime
return c.tokenMetadata, nil
}
// RequireTransportSecurity indicates whether the credentials requires
// transport security.
func (c *callCreds) RequireTransportSecurity() bool {
return true
}
// httpDoer wraps the single method on the http.Client type that we use. This
// helps with overriding in unittests.
type httpDoer interface {
Do(req *http.Request) (*http.Response, error)
}
func makeHTTPClient(roots *x509.CertPool) httpDoer {
return &http.Client{
Timeout: stsRequestTimeout,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: roots,
},
},
}
}
// validateOptions performs the following validation checks on opts:
// - tokenExchangeServiceURI is not empty
// - tokenExchangeServiceURI is a valid URI with a http(s) scheme
// - subjectTokenPath and subjectTokenType are not empty.
func validateOptions(opts Options) error {
if opts.TokenExchangeServiceURI == "" {
return errors.New("empty token_exchange_service_uri in options")
}
u, err := url.Parse(opts.TokenExchangeServiceURI)
if err != nil {
return err
}
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("scheme is not supported: %q. Only http(s) is supported", u.Scheme)
}
if opts.SubjectTokenPath == "" {
return errors.New("required field SubjectTokenPath is not specified")
}
if opts.SubjectTokenType == "" {
return errors.New("required field SubjectTokenType is not specified")
}
return nil
}
// cachedMetadata returns the cached metadata provided it is not going to
// expire anytime soon.
//
// Caller must hold c.mu.
func (c *callCreds) cachedMetadata() map[string]string {
now := time.Now()
// If the cached token has not expired and the lifetime remaining on that
// token is greater than the minimum value we are willing to accept, go
// ahead and use it.
if c.tokenExpiry.After(now) && c.tokenExpiry.Sub(now) > minCachedTokenLifetime {
return c.tokenMetadata
}
return nil
}
// constructRequest creates the STS request body in JSON based on the provided
// options.
// - Contents of the subjectToken are read from the file specified in
// options. If we encounter an error here, we bail out.
// - Contents of the actorToken are read from the file specified in options.
// If we encounter an error here, we ignore this field because this is
// optional.
// - Most of the other fields in the request come directly from options.
//
// A new HTTP request is created by calling http.NewRequestWithContext() and
// passing the provided context, thereby enforcing any timeouts specified in
// the latter.
func constructRequest(ctx context.Context, opts Options) (*http.Request, error) {
subToken, err := readSubjectTokenFrom(opts.SubjectTokenPath)
if err != nil {
return nil, err
}
reqScope := opts.Scope
if reqScope == "" {
reqScope = defaultCloudPlatformScope
}
reqParams := &requestParameters{
GrantType: tokenExchangeGrantType,
Resource: opts.Resource,
Audience: opts.Audience,
Scope: reqScope,
RequestedTokenType: opts.RequestedTokenType,
SubjectToken: string(subToken),
SubjectTokenType: opts.SubjectTokenType,
}
if opts.ActorTokenPath != "" {
actorToken, err := readActorTokenFrom(opts.ActorTokenPath)
if err != nil {
return nil, err
}
reqParams.ActorToken = string(actorToken)
reqParams.ActorTokenType = opts.ActorTokenType
}
jsonBody, err := json.Marshal(reqParams)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, "POST", opts.TokenExchangeServiceURI, bytes.NewBuffer(jsonBody))
if err != nil {
return nil, fmt.Errorf("failed to create http request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
return req, nil
}
func sendRequest(client httpDoer, req *http.Request) ([]byte, error) {
// http.Client returns a non-nil error only if it encounters an error
// caused by client policy (such as CheckRedirect), or failure to speak
// HTTP (such as a network connectivity problem). A non-2xx status code
// doesn't cause an error.
resp, err := client.Do(req)
if err != nil {
return nil, err
}
// When the http.Client returns a non-nil error, it is the
// responsibility of the caller to read the response body till an EOF is
// encountered and to close it.
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusOK {
return body, nil
}
logger.Warningf("http status %d, body: %s", resp.StatusCode, string(body))
return nil, fmt.Errorf("http status %d, body: %s", resp.StatusCode, string(body))
}
func tokenInfoFromResponse(respBody []byte) (*tokenInfo, error) {
respData := &responseParameters{}
if err := json.Unmarshal(respBody, respData); err != nil {
return nil, fmt.Errorf("json.Unmarshal(%v): %v", respBody, err)
}
if respData.AccessToken == "" {
return nil, fmt.Errorf("empty accessToken in response (%v)", string(respBody))
}
return &tokenInfo{
tokenType: respData.TokenType,
token: respData.AccessToken,
expiryTime: time.Now().Add(time.Duration(respData.ExpiresIn) * time.Second),
}, nil
}
// requestParameters stores all STS request attributes defined in
// https://tools.ietf.org/html/rfc8693#section-2.1.
type requestParameters struct {
// REQUIRED. The value "urn:ietf:params:oauth:grant-type:token-exchange"
// indicates that a token exchange is being performed.
GrantType string `json:"grant_type"`
// OPTIONAL. Indicates the location of the target service or resource where
// the client intends to use the requested security token.
Resource string `json:"resource,omitempty"`
// OPTIONAL. The logical name of the target service where the client intends
// to use the requested security token.
Audience string `json:"audience,omitempty"`
// OPTIONAL. A list of space-delimited, case-sensitive strings, that allow
// the client to specify the desired scope of the requested security token
// in the context of the service or Resource where the token will be used.
Scope string `json:"scope,omitempty"`
// OPTIONAL. An identifier, for the type of the requested security token.
RequestedTokenType string `json:"requested_token_type,omitempty"`
// REQUIRED. A security token that represents the identity of the party on
// behalf of whom the request is being made.
SubjectToken string `json:"subject_token"`
// REQUIRED. An identifier, that indicates the type of the security token in
// the "subject_token" parameter.
SubjectTokenType string `json:"subject_token_type"`
// OPTIONAL. A security token that represents the identity of the acting
// party.
ActorToken string `json:"actor_token,omitempty"`
// An identifier, that indicates the type of the security token in the
// "actor_token" parameter.
ActorTokenType string `json:"actor_token_type,omitempty"`
}
// nesponseParameters stores all attributes sent as JSON in a successful STS
// response. These attributes are defined in
// https://tools.ietf.org/html/rfc8693#section-2.2.1.
type responseParameters struct {
// REQUIRED. The security token issued by the authorization server
// in response to the token exchange request.
AccessToken string `json:"access_token"`
// REQUIRED. An identifier, representation of the issued security token.
IssuedTokenType string `json:"issued_token_type"`
// REQUIRED. A case-insensitive value specifying the method of using the access
// token issued. It provides the client with information about how to utilize the
// access token to access protected resources.
TokenType string `json:"token_type"`
// RECOMMENDED. The validity lifetime, in seconds, of the token issued by the
// authorization server.
ExpiresIn int64 `json:"expires_in"`
// OPTIONAL, if the Scope of the issued security token is identical to the
// Scope requested by the client; otherwise, REQUIRED.
Scope string `json:"scope"`
// OPTIONAL. A refresh token will typically not be issued when the exchange is
// of one temporary credential (the subject_token) for a different temporary
// credential (the issued token) for use in some other context.
RefreshToken string `json:"refresh_token"`
}
// tokenInfo wraps the information received in a successful STS response.
type tokenInfo struct {
tokenType string
token string
expiryTime time.Time
}