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 }