pkg/sts/sts.go (388 lines of code) (raw):
// Copyright 2021 Google LLC
//
// 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
//
// https://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
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/http/httputil"
"strings"
"time"
"github.com/GoogleCloudPlatform/cloud-run-mesh/pkg/mesh"
"golang.org/x/oauth2"
)
// From nodeagent/plugin/providers/google/stsclient
// In Istio, the code is used if "GoogleCA" is set as CA_PROVIDER or CA_ADDR has the right prefix
var (
// SecureTokenEndpoint is the Endpoint the STS client calls to.
SecureTokenEndpoint = "https://sts.googleapis.com/v1/token"
httpTimeout = time.Second * 5
contentType = "application/json"
Scope = "https://www.googleapis.com/auth/cloud-platform"
accessTokenEndpoint = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateAccessToken"
idTokenEndpoint = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:generateIdToken"
// Server side
// TokenPath is url path for handling STS requests.
TokenPath = "/token"
// StsStatusPath is the path for dumping STS status.
StsStatusPath = "/stsStatus"
// URLEncodedForm is the encoding type specified in a STS request.
URLEncodedForm = "application/x-www-form-urlencoded"
// TokenExchangeGrantType is the required value for "grant_type" parameter in a STS request.
TokenExchangeGrantType = "urn:ietf:params:oauth:grant-type:token-exchange"
// SubjectTokenType is the required token type in a STS request.
SubjectTokenType = "urn:ietf:params:oauth:token-type:jwt"
Debug = false
)
// error code sent in a STS error response. A full list of error code is
// defined in https://tools.ietf.org/html/rfc6749#section-5.2.
const (
// If the request itself is not valid or if either the "subject_token" or
// "actor_token" are invalid or unacceptable, the STS server must set
// error code to "invalid_request".
invalidRequest = "invalid_request"
// If the authorization server is unwilling or unable to issue a token, the
// STS server should set error code to "invalid_target".
invalidTarget = "invalid_target"
stsIssuedTokenType = "urn:ietf:params:oauth:token-type:access_token"
)
// STS provides token exchanges. Implements grpc and golang.org/x/oauth2.TokenSource
// The source of trust is the K8S token with TrustDomain audience, it is exchanged with access or ID tokens.
type STS struct {
httpClient *http.Client
kr *mesh.KRun
// Google service account to impersonate and return tokens for.
// The KSA returned from K8S must have the IAM permissions
GSA string
// Use mesh data plane SA.
MDPSA bool
UseAccessToken bool
}
func NewSTS(kr *mesh.KRun) (*STS, error) {
caCertPool, err := x509.SystemCertPool()
if err != nil {
return nil, err
}
return &STS{
kr: kr,
httpClient: &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
RootCAs: caCertPool,
},
},
},
}, nil
}
// Implements oauth2.TokenSource - returning access tokens
// May return federated token or service account tokens
func (s *STS) Token() (*oauth2.Token, error) {
mv, err := s.GetRequestMetadata(context.Background())
if err != nil {
return nil, err
}
a := mv["authorization"]
// WIP - split, etc
t := &oauth2.Token{
AccessToken: a,
}
return t, nil
}
// GetRequestMetadata implements credentials.PerRPCCredentials
// This can be used for both ID tokens or access tokens - if the 'aud' containts googleapis.com, access tokens are returned.
func (s *STS) GetRequestMetadata(ctx context.Context, aud ...string) (map[string]string, error) {
// The K8S-signed JWT
kt, err := s.kr.GetToken(ctx, s.kr.TrustDomain)
if err != nil {
return nil, err
}
// Federated token - a google token equivalent with the k8s JWT, using STS
ft, err := s.TokenFederated(ctx, kt)
if err != nil {
return nil, err
}
a0 := ""
if len(aud) > 0 {
a0 = aud[0]
}
if len(aud) > 1 {
return nil, errors.New("Single audience supporte")
}
// TODO: better way to determine if the destination supports federated token directly.
if !s.MDPSA && strings.Contains(a0, "googleapis.com/") {
return map[string]string{
"authorization": "Bearer " + ft,
}, nil
}
token, err := s.TokenAccess(ctx, ft, a0)
if err != nil {
return nil, err
}
return map[string]string{
"authorization": "Bearer " + token,
}, nil
}
func (s *STS) RequireTransportSecurity() bool {
return false
}
// TokenFederated exchanges the K8S JWT with a federated token
// (former ExchangeToken)
func (s *STS) TokenFederated(ctx context.Context, k8sSAjwt string) (string, error) {
stsAud := s.constructAudience("", s.kr.TrustDomain)
jsonStr, err := s.constructFederatedTokenRequest(stsAud, k8sSAjwt)
if err != nil {
return "", fmt.Errorf("failed to marshal federated token request: %v", err)
}
req, err := http.NewRequest("POST", SecureTokenEndpoint, bytes.NewBuffer(jsonStr))
req = req.WithContext(ctx)
res, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("token exchange failed: %v, (aud: %s, STS endpoint: %s)", err, stsAud, SecureTokenEndpoint)
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("token exchange read failed: %v, (aud: %s, STS endpoint: %s)", err, stsAud, SecureTokenEndpoint)
}
respData := &federatedTokenResponse{}
if err := json.Unmarshal(body, respData); err != nil {
// Normally the request should json - extremely hard to debug otherwise, not enough info in status/err
log.Println("Unexpected unmarshal error, response was ", string(body))
return "", fmt.Errorf("(aud: %s, STS endpoint: %s), failed to unmarshal response data of size %v: %v",
stsAud, SecureTokenEndpoint, len(body), err)
}
if respData.AccessToken == "" {
return "", fmt.Errorf(
"exchanged empty token (aud: %s, STS endpoint: %s), response: %v", stsAud, SecureTokenEndpoint, string(body))
}
return respData.AccessToken, nil
}
// Exchange a federated token equivalent with the k8s JWT with the ASM p4SA.
// TODO: can be used with any GSA, if the permission to call generateAccessToken is granted.
// This is a good way to get access tokens for a GSA using the KSA, similar with TokenRequest in
// the other direction.
//
// May return an ID token with aud or access token.
func (s *STS) TokenAccess(ctx context.Context, federatedToken string, audience string) (string, error) {
req, err := s.constructGenerateAccessTokenRequest(federatedToken, audience)
if err != nil {
return "", fmt.Errorf("failed to marshal federated token request: %v", err)
}
req = req.WithContext(ctx)
res, err := s.httpClient.Do(req)
if err != nil {
return "", err
}
body, err := ioutil.ReadAll(res.Body)
if err != nil {
return "", fmt.Errorf("token exchange failed: %v", err)
}
if audience == "" || s.UseAccessToken {
respData := &accessTokenResponse{}
if err := json.Unmarshal(body, respData); err != nil {
// Normally the request should json - extremely hard to debug otherwise, not enough info in status/err
log.Println("Unexpected unmarshal error, response was ", string(body))
return "", fmt.Errorf("failed to unmarshal response data of size %v: %v",
len(body), err)
}
if respData.AccessToken == "" {
return "", fmt.Errorf(
"exchanged empty token, response: %v", string(body))
}
return respData.AccessToken, nil
}
respData := &idTokenResponse{}
if err := json.Unmarshal(body, respData); err != nil {
// Normally the request should json - extremely hard to debug otherwise, not enough info in status/err
log.Println("Unexpected unmarshal error, response was ", string(body))
return "", fmt.Errorf("failed to unmarshal response data of size %v: %v",
len(body), err)
}
if respData.Token == "" {
return "", fmt.Errorf(
"exchanged empty token, response: %v", string(body))
}
return respData.Token, nil
}
type federatedTokenResponse struct {
AccessToken string `json:"access_token"`
IssuedTokenType string `json:"issued_token_type"`
TokenType string `json:"token_type"`
ExpiresIn int64 `json:"expires_in"` // Expiration time in seconds
}
// provider can be extracted from metadata server, or is set using GKE_ClusterURL
//
// For VMs, it is set as GoogleComputeEngine via CREDENTIAL_IDENTITY_PROVIDER env
// In Istio GKE it is constructed from metadata, on VM it is GKE_CLUSTER_URL or gcp_gke_cluster_url,
// format "https://container.googleapis.com/v1/projects/%s/locations/%s/clusters/%s" - this also happens to be
// the 'iss' field in the token.
// According to docs, aud can be:
// iam.googleapis.com/projects/<project-number>/locations/global/workloadIdentityPools/<pool-id>/providers/<provider-id>.
// or gcloud URL
// Required when exchanging an external credential for a Google access token.
func (s *STS) constructAudience(provider, trustDomain string) string {
if provider == "" {
provider = s.kr.ClusterAddress
}
return fmt.Sprintf("identitynamespace:%s:%s", trustDomain, provider)
}
// fetchFederatedToken exchanges a third-party issued Json Web Token for an OAuth2.0 access token
// which asserts a third-party identity within an identity namespace.
func (s *STS) constructFederatedTokenRequest(aud, jwt string) ([]byte, error) {
values := map[string]string{
"grantType": "urn:ietf:params:oauth:grant-type:token-exchange", // fixed, no options
"subjectTokenType": "urn:ietf:params:oauth:token-type:jwt",
"requestedTokenType": "urn:ietf:params:oauth:token-type:access_token",
"audience": aud, // full name if the identity provider.
"subjectToken": jwt,
"scope": Scope, // required for the GCP exchanges
}
// golang sts also includes:
jsonValue, err := json.Marshal(values)
return jsonValue, err
}
// from security/security.go
// StsRequestParameters stores all STS request attributes defined in
// https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16#section-2.1
type StsRequestParameters struct {
// REQUIRED. The value "urn:ietf:params:oauth:grant-type:token- exchange"
// indicates that a token exchange is being performed.
GrantType string
// OPTIONAL. Indicates the location of the target service or resource where
// the client intends to use the requested security token.
Resource string
// OPTIONAL. The logical name of the target service where the client intends
// to use the requested security token.
Audience string
// 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
// OPTIONAL. An identifier, for the type of the requested security token.
RequestedTokenType string
// REQUIRED. A security token that represents the identity of the party on
// behalf of whom the request is being made.
SubjectToken string
// REQUIRED. An identifier, that indicates the type of the security token in
// the "subject_token" parameter.
SubjectTokenType string
// OPTIONAL. A security token that represents the identity of the acting party.
ActorToken string
// An identifier, that indicates the type of the security token in the
// "actor_token" parameter.
ActorTokenType string
}
// From stsservice/sts.go
// StsResponseParameters stores all attributes sent as JSON in a successful STS
// response. These attributes are defined in
// https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16#section-2.2.1
type StsResponseParameters 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"`
}
// From tokenexchangeplugin.go
type Duration struct {
// Signed seconds of the span of time. Must be from -315,576,000,000
// to +315,576,000,000 inclusive. Note: these bounds are computed from:
// 60 sec/min * 60 min/hr * 24 hr/day * 365.25 days/year * 10000 years
Seconds int64 `json:"seconds"`
}
type accessTokenRequest struct {
Name string `json:"name"` // nolint: structcheck, unused
Delegates []string `json:"delegates"`
Scope []string `json:"scope"`
LifeTime Duration `json:"lifetime"` // nolint: structcheck, unused
}
type idTokenRequest struct {
Audience string `json:"audience"` // nolint: structcheck, unused
Delegates []string `json:"delegates"`
IncludeEmail bool `json:"includeEmail"`
}
type accessTokenResponse struct {
AccessToken string `json:"accessToken"`
ExpireTime string `json:"expireTime"`
}
type idTokenResponse struct {
Token string `json:"token"`
}
// constructFederatedTokenRequest returns an HTTP request for access token.
// Example of an access token request:
// POST https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/
// service-<GCP project number>@gcp-sa-meshdataplane.iam.gserviceaccount.com:generateAccessToken
// Content-Type: application/json
// Authorization: Bearer <federated token>
// {
// "Delegates": [],
// "Scope": [
// https://www.googleapis.com/auth/cloud-platform
// ],
// }
//
// This requires permission to impersonate:
// gcloud iam service-accounts add-iam-policy-binding \
// GSA_NAME@GSA_PROJECT_ID.iam.gserviceaccount.com \
// --role=roles/iam.workloadIdentityUser \
// --member="serviceAccount:WORKLOAD_IDENTITY_POOL[K8S_NAMESPACE/KSA_NAME]"
//
// The p4sa is auto-setup for all authenticated users.
func (s *STS) constructGenerateAccessTokenRequest(fResp string, audience string) (*http.Request, error) {
gsa := "service-" + s.kr.ProjectNumber + "@gcp-sa-meshdataplane.iam.gserviceaccount.com"
if s.GSA != "" {
gsa = s.GSA
}
endpoint := ""
var err error
var jsonQuery []byte
if audience == "" || s.UseAccessToken {
endpoint = fmt.Sprintf(accessTokenEndpoint, gsa)
// Request for access token with a lifetime of 3600 seconds.
query := accessTokenRequest{
LifeTime: Duration{Seconds: 3600},
}
query.Scope = append(query.Scope, Scope)
jsonQuery, err = json.Marshal(query)
if err != nil {
return nil, fmt.Errorf("failed to marshal query for get access token request: %+v", err)
}
} else {
endpoint = fmt.Sprintf(idTokenEndpoint, gsa)
// Request for access token with a lifetime of 3600 seconds.
query := idTokenRequest{
IncludeEmail: true,
Audience: audience,
}
jsonQuery, err = json.Marshal(query)
if err != nil {
return nil, fmt.Errorf("failed to marshal query for get access token request: %+v", err)
}
}
req, err := http.NewRequest("POST", endpoint, bytes.NewBuffer(jsonQuery))
if err != nil {
return nil, fmt.Errorf("failed to create get access token request: %+v", err)
}
req.Header.Add("Content-Type", contentType)
if Debug {
reqDump, _ := httputil.DumpRequest(req, true)
log.Println("Prepared access token request: ", string(reqDump))
}
req.Header.Add("Authorization", "Bearer "+fResp) // the AccessToken
return req, nil
}
// ServeStsRequests handles STS requests and sends exchanged token in responses.
func (s *STS) ServeStsRequests(w http.ResponseWriter, req *http.Request) {
reqParam, validationError := s.validateStsRequest(req)
if validationError != nil {
// If request is invalid, the error code must be "invalid_request".
// https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16#section-2.2.2.
s.sendErrorResponse(w, invalidRequest, validationError)
return
}
// We start with reqParam.SubjectToken - loaded from the file by the client.
// Must be a K8S Token with right trust domain
ft, err := s.TokenFederated(req.Context(), reqParam.SubjectToken)
if err != nil {
s.sendErrorResponse(w, invalidTarget, err)
return
}
at, err := s.TokenAccess(req.Context(), ft, "")
if err != nil {
log.Printf("token manager fails to generate token: %v", err)
// If the authorization server is unable to issue a token, the "invalid_target" error code
// should be used in the error response.
// https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16#section-2.2.2.
s.sendErrorResponse(w, invalidTarget, err)
return
}
s.sendSuccessfulResponse(w, s.generateSTSRespInner(at))
}
func (p *STS) generateSTSRespInner(token string) []byte {
//exp, err := time.Parse(time.RFC3339Nano, atResp.ExpireTime)
// Default token life time is 3600 seconds
var expireInSec int64 = 3600
//if err == nil {
// expireInSec = int64(time.Until(exp).Seconds())
//}
stsRespParam := StsResponseParameters{
AccessToken: token,
IssuedTokenType: stsIssuedTokenType,
TokenType: "Bearer",
ExpiresIn: expireInSec,
}
statusJSON, _ := json.MarshalIndent(stsRespParam, "", " ")
return statusJSON
}
// validateStsRequest validates a STS request, and extracts STS parameters from the request.
func (s *STS) validateStsRequest(req *http.Request) (StsRequestParameters, error) {
reqParam := StsRequestParameters{}
if req == nil {
return reqParam, errors.New("request is nil")
}
//if stsServerLog.DebugEnabled() {
// reqDump, _ := httputil.DumpRequest(req, true)
// stsServerLog.Debugf("Received STS request: %s", string(reqDump))
//}
if req.Method != "POST" {
return reqParam, fmt.Errorf("request method is invalid, should be POST but get %s", req.Method)
}
if req.Header.Get("Content-Type") != URLEncodedForm {
return reqParam, fmt.Errorf("request content type is invalid, should be %s but get %s", URLEncodedForm,
req.Header.Get("Content-type"))
}
if parseErr := req.ParseForm(); parseErr != nil {
return reqParam, fmt.Errorf("failed to parse query from STS request: %v", parseErr)
}
if req.PostForm.Get("grant_type") != TokenExchangeGrantType {
return reqParam, fmt.Errorf("request query grant_type is invalid, should be %s but get %s",
TokenExchangeGrantType, req.PostForm.Get("grant_type"))
}
// Only a JWT token is accepted.
if req.PostForm.Get("subject_token") == "" {
return reqParam, errors.New("subject_token is empty")
}
if req.PostForm.Get("subject_token_type") != SubjectTokenType {
return reqParam, fmt.Errorf("subject_token_type is invalid, should be %s but get %s",
SubjectTokenType, req.PostForm.Get("subject_token_type"))
}
reqParam.GrantType = req.PostForm.Get("grant_type")
reqParam.Resource = req.PostForm.Get("resource")
reqParam.Audience = req.PostForm.Get("audience")
reqParam.Scope = req.PostForm.Get("scope")
reqParam.RequestedTokenType = req.PostForm.Get("requested_token_type")
reqParam.SubjectToken = req.PostForm.Get("subject_token")
reqParam.SubjectTokenType = req.PostForm.Get("subject_token_type")
reqParam.ActorToken = req.PostForm.Get("actor_token")
reqParam.ActorTokenType = req.PostForm.Get("actor_token_type")
return reqParam, nil
}
// StsErrorResponse stores all Error parameters sent as JSON in a STS Error response.
// The Error parameters are defined in
// https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16#section-2.2.2.
type StsErrorResponse struct {
// REQUIRED. A single ASCII Error code.
Error string `json:"error"`
// OPTIONAL. Human-readable ASCII [USASCII] text providing additional information.
ErrorDescription string `json:"error_description"`
// OPTIONAL. A URI identifying a human-readable web page with information
// about the Error.
ErrorURI string `json:"error_uri"`
}
// sendErrorResponse takes error type and error details, generates an error response and sends out.
func (s *STS) sendErrorResponse(w http.ResponseWriter, errorType string, errDetail error) {
w.Header().Add("Content-Type", "application/json")
if errorType == invalidRequest {
w.WriteHeader(http.StatusBadRequest)
} else {
w.WriteHeader(http.StatusInternalServerError)
}
errResp := StsErrorResponse{
Error: errorType,
ErrorDescription: errDetail.Error(),
}
if errRespJSON, err := json.MarshalIndent(errResp, "", " "); err == nil {
if _, err := w.Write(errRespJSON); err != nil {
return
}
} else {
log.Printf("failure in marshaling error response (%v) into JSON: %v", errResp, err)
}
}
// sendSuccessfulResponse takes token data and generates a successful STS response, and sends out the STS response.
func (s *STS) sendSuccessfulResponse(w http.ResponseWriter, tokenData []byte) {
w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
if _, err := w.Write(tokenData); err != nil {
log.Printf("failure in sending STS success response: %v", err)
return
}
}
// TokenPayload returns the decoded token. Used for logging/debugging token content, without printing the signature.
func TokenPayload(jwt string) string {
jwtSplit := strings.Split(jwt, ".")
if len(jwtSplit) != 3 {
return ""
}
//azp,"email","exp":1629832319,"iss":"https://accounts.google.com","sub":"1118295...
payload := jwtSplit[1]
payloadBytes, err := base64.RawStdEncoding.DecodeString(payload)
if err != nil {
return ""
}
return string(payloadBytes)
}