go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/common/gcloud/iam/credentials.go (about)

     1  // Copyright 2016 The LUCI Authors.
     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 iam
    16  
    17  import (
    18  	"bytes"
    19  	"context"
    20  	"encoding/json"
    21  	"fmt"
    22  	"io"
    23  	"net/http"
    24  	"net/url"
    25  	"time"
    26  
    27  	"golang.org/x/oauth2"
    28  	"google.golang.org/api/googleapi"
    29  
    30  	"go.chromium.org/luci/common/logging"
    31  )
    32  
    33  // IAM credentials service URL used in non-test code.
    34  const iamCredentialsBackend = "https://iamcredentials.googleapis.com"
    35  
    36  // ClaimSet contains information about the JWT signature including the
    37  // permissions being requested (scopes), the target of the token, the issuer,
    38  // the time the token was issued, and the lifetime of the token.
    39  //
    40  // See RFC 7515.
    41  type ClaimSet struct {
    42  	Iss   string `json:"iss"`             // email address of the client_id of the application making the access token request
    43  	Scope string `json:"scope,omitempty"` // space-delimited list of the permissions the application requests
    44  	Aud   string `json:"aud"`             // descriptor of the intended target of the assertion (Optional).
    45  	Exp   int64  `json:"exp"`             // the expiration time of the assertion (seconds since Unix epoch)
    46  	Iat   int64  `json:"iat"`             // the time the assertion was issued (seconds since Unix epoch)
    47  	Typ   string `json:"typ,omitempty"`   // token type (Optional).
    48  
    49  	// Email for which the application is requesting delegated access (Optional).
    50  	Sub string `json:"sub,omitempty"`
    51  }
    52  
    53  // CredentialsClient knows how to perform IAM Credentials API v1 calls.
    54  //
    55  // DEPRECATED: Prefer to use cloud.google.com/go/iam/credentials/apiv1 instead
    56  // if possible.
    57  type CredentialsClient struct {
    58  	Client *http.Client // the HTTP client to use to make calls
    59  
    60  	backendURL string // replaceable in tests, defaults to iamCredentialsBackend
    61  }
    62  
    63  // SignBlob signs a blob using a service account's system-managed key.
    64  //
    65  // The caller must have "roles/iam.serviceAccountTokenCreator" role in the
    66  // service account's IAM policy and caller's OAuth token must have one of the
    67  // scopes:
    68  //   - https://www.googleapis.com/auth/iam
    69  //   - https://www.googleapis.com/auth/cloud-platform
    70  //
    71  // Returns ID of the signing key and the signature on success.
    72  //
    73  // On API-level errors (e.g. insufficient permissions) returns *googleapi.Error.
    74  func (cl *CredentialsClient) SignBlob(ctx context.Context, serviceAccount string, blob []byte) (keyName string, signature []byte, err error) {
    75  	var request struct {
    76  		Payload []byte `json:"payload"`
    77  	}
    78  	request.Payload = blob
    79  	var response struct {
    80  		KeyID      string `json:"keyId"`
    81  		SignedBlob []byte `json:"signedBlob"`
    82  	}
    83  	if err = cl.request(ctx, serviceAccount, "signBlob", &request, &response); err != nil {
    84  		return "", nil, err
    85  	}
    86  	return response.KeyID, response.SignedBlob, nil
    87  }
    88  
    89  // SignJWT signs a claim set using a service account's system-managed key.
    90  //
    91  // It injects the key ID into the JWT header before singing. As a result, JWTs
    92  // produced by SignJWT are slightly faster to verify, because we know what
    93  // public key to use exactly and don't need to enumerate all active keys.
    94  //
    95  // It also checks the expiration time and refuses to sign claim sets with
    96  // 'exp' set to more than 12h from now. Otherwise it is similar to SignBlob.
    97  //
    98  // The caller must have "roles/iam.serviceAccountTokenCreator" role in the
    99  // service account's IAM policy and caller's OAuth token must have one of the
   100  // scopes:
   101  //   - https://www.googleapis.com/auth/iam
   102  //   - https://www.googleapis.com/auth/cloud-platform
   103  //
   104  // Returns ID of the signing key and the signed JWT on success.
   105  //
   106  // On API-level errors (e.g. insufficient permissions) returns *googleapi.Error.
   107  func (cl *CredentialsClient) SignJWT(ctx context.Context, serviceAccount string, cs *ClaimSet) (keyName, signedJwt string, err error) {
   108  	blob, err := json.Marshal(cs)
   109  	if err != nil {
   110  		return "", "", err
   111  	}
   112  	var request struct {
   113  		Payload string `json:"payload"`
   114  	}
   115  	request.Payload = string(blob) // yep, this is JSON inside JSON
   116  	var response struct {
   117  		KeyID     string `json:"keyId"`
   118  		SignedJwt string `json:"signedJwt"`
   119  	}
   120  	if err = cl.request(ctx, serviceAccount, "signJwt", &request, &response); err != nil {
   121  		return "", "", err
   122  	}
   123  	return response.KeyID, response.SignedJwt, nil
   124  }
   125  
   126  // GenerateAccessToken creates a service account OAuth token using IAM's
   127  // :generateAccessToken API.
   128  //
   129  // On non-success HTTP status codes returns googleapi.Error.
   130  func (cl *CredentialsClient) GenerateAccessToken(ctx context.Context, serviceAccount string, scopes []string, delegates []string, lifetime time.Duration) (*oauth2.Token, error) {
   131  	var body struct {
   132  		Delegates []string `json:"delegates,omitempty"`
   133  		Scope     []string `json:"scope"`
   134  		Lifetime  string   `json:"lifetime,omitempty"`
   135  	}
   136  	body.Delegates = delegates
   137  	body.Scope = scopes
   138  
   139  	// Default lifetime is 3600 seconds according to API documentation.
   140  	// Requesting longer lifetime will cause an API error which is
   141  	// forwarded to the caller.
   142  	if lifetime > 0 {
   143  		body.Lifetime = lifetime.String()
   144  	}
   145  
   146  	var resp struct {
   147  		AccessToken string `json:"accessToken"`
   148  		ExpireTime  string `json:"expireTime"`
   149  	}
   150  	if err := cl.request(ctx, serviceAccount, "generateAccessToken", &body, &resp); err != nil {
   151  		return nil, err
   152  	}
   153  
   154  	expires, err := time.Parse(time.RFC3339, resp.ExpireTime)
   155  	if err != nil {
   156  		return nil, fmt.Errorf("unable to parse 'expireTime': %s", resp.ExpireTime)
   157  	}
   158  
   159  	return &oauth2.Token{
   160  		AccessToken: resp.AccessToken,
   161  		TokenType:   "Bearer",
   162  		Expiry:      expires.UTC(),
   163  	}, nil
   164  }
   165  
   166  // GenerateIDToken creates a service account OpenID Connect ID token using IAM's
   167  // :generateIdToken API.
   168  //
   169  // On non-success HTTP status codes returns googleapi.Error.
   170  func (cl *CredentialsClient) GenerateIDToken(ctx context.Context, serviceAccount string, audience string, includeEmail bool, delegates []string) (string, error) {
   171  	var body struct {
   172  		Delegates    []string `json:"delegates,omitempty"`
   173  		Audience     string   `json:"audience"`
   174  		IncludeEmail bool     `json:"includeEmail,omitempty"`
   175  	}
   176  	body.Delegates = delegates
   177  	body.Audience = audience
   178  	body.IncludeEmail = includeEmail
   179  
   180  	var resp struct {
   181  		Token string `json:"token"`
   182  	}
   183  	if err := cl.request(ctx, serviceAccount, "generateIdToken", &body, &resp); err != nil {
   184  		return "", err
   185  	}
   186  	return resp.Token, nil
   187  }
   188  
   189  // request performs HTTP POST to the IAM credentials API endpoint.
   190  func (cl *CredentialsClient) request(ctx context.Context, serviceAccount, action string, body, resp any) error {
   191  	// Construct the target POST URL.
   192  	backendURL := cl.backendURL
   193  	if backendURL == "" {
   194  		backendURL = iamCredentialsBackend
   195  	}
   196  	url := fmt.Sprintf("%s/v1/projects/-/serviceAccounts/%s:%s?alt=json",
   197  		backendURL,
   198  		url.QueryEscape(serviceAccount),
   199  		action,
   200  	)
   201  
   202  	// Serialize the body.
   203  	var reader io.Reader
   204  	if body != nil {
   205  		blob, err := json.Marshal(body)
   206  		if err != nil {
   207  			return err
   208  		}
   209  		reader = bytes.NewReader(blob)
   210  	}
   211  
   212  	// Issue the request.
   213  	req, err := http.NewRequest("POST", url, reader)
   214  	if err != nil {
   215  		return err
   216  	}
   217  	if reader != nil {
   218  		req.Header.Set("Content-Type", "application/json")
   219  	}
   220  
   221  	// Send and handle errors. This is roughly how google-api-go-client calls
   222  	// methods. CheckResponse returns *googleapi.Error.
   223  	logging.Debugf(ctx, "POST %s", url)
   224  	res, err := cl.Client.Do(req.WithContext(ctx))
   225  	if err != nil {
   226  		return err
   227  	}
   228  	defer googleapi.CloseBody(res)
   229  	if err := googleapi.CheckResponse(res); err != nil {
   230  		logging.WithError(err).Errorf(ctx, "POST %s failed", url)
   231  		return err
   232  	}
   233  	return json.NewDecoder(res.Body).Decode(resp)
   234  }