/* * * 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/ioutil" "net/http" "net/url" "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 = ioutil.ReadFile readActorTokenFrom = ioutil.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 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 := ioutil.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 }