1 /* 2 * Copyright 2022 Google LLC 3 * 4 * Redistribution and use in source and binary forms, with or without 5 * modification, are permitted provided that the following conditions are 6 * met: 7 * 8 * * Redistributions of source code must retain the above copyright 9 * notice, this list of conditions and the following disclaimer. 10 * * Redistributions in binary form must reproduce the above 11 * copyright notice, this list of conditions and the following disclaimer 12 * in the documentation and/or other materials provided with the 13 * distribution. 14 * 15 * * Neither the name of Google LLC nor the names of its 16 * contributors may be used to endorse or promote products derived from 17 * this software without specific prior written permission. 18 * 19 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 20 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 21 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 22 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 23 * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 24 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 25 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 26 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 27 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 28 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 */ 31 32 package com.google.auth.oauth2; 33 34 import static com.google.auth.oauth2.OAuth2Utils.JSON_FACTORY; 35 import static com.google.common.base.Preconditions.checkNotNull; 36 37 import com.google.api.client.http.GenericUrl; 38 import com.google.api.client.http.HttpHeaders; 39 import com.google.api.client.http.HttpRequest; 40 import com.google.api.client.http.HttpResponse; 41 import com.google.api.client.http.HttpResponseException; 42 import com.google.api.client.http.UrlEncodedContent; 43 import com.google.api.client.json.GenericJson; 44 import com.google.api.client.json.JsonObjectParser; 45 import com.google.api.client.util.GenericData; 46 import com.google.api.client.util.Preconditions; 47 import com.google.auth.http.HttpTransportFactory; 48 import com.google.common.base.MoreObjects; 49 import com.google.common.io.BaseEncoding; 50 import com.google.errorprone.annotations.CanIgnoreReturnValue; 51 import java.io.IOException; 52 import java.io.InputStream; 53 import java.io.ObjectInputStream; 54 import java.nio.charset.StandardCharsets; 55 import java.util.Date; 56 import java.util.Map; 57 import java.util.Objects; 58 import javax.annotation.Nullable; 59 60 /** 61 * OAuth2 credentials sourced using external identities through Workforce Identity Federation. 62 * 63 * <p>Obtaining the initial access and refresh token can be done through the Google Cloud CLI. 64 * 65 * <pre> 66 * Example credentials file: 67 * { 68 * "type": "external_account_authorized_user", 69 * "audience": "//iam.googleapis.com/locations/global/workforcePools/$WORKFORCE_POOL_ID/providers/$PROVIDER_ID", 70 * "refresh_token": "refreshToken", 71 * "token_url": "https://sts.googleapis.com/v1/oauthtoken", 72 * "token_info_url": "https://sts.googleapis.com/v1/introspect", 73 * "client_id": "clientId", 74 * "client_secret": "clientSecret" 75 * } 76 * </pre> 77 */ 78 public class ExternalAccountAuthorizedUserCredentials extends GoogleCredentials { 79 80 private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; 81 82 private static final long serialVersionUID = -2181779590486283287L; 83 84 static final String EXTERNAL_ACCOUNT_AUTHORIZED_USER_FILE_TYPE = 85 "external_account_authorized_user"; 86 87 private final String transportFactoryClassName; 88 private final String audience; 89 private final String tokenUrl; 90 private final String tokenInfoUrl; 91 private final String revokeUrl; 92 private final String clientId; 93 private final String clientSecret; 94 95 private String refreshToken; 96 97 private transient HttpTransportFactory transportFactory; 98 99 /** 100 * Internal constructor. 101 * 102 * @param builder A builder for {@link ExternalAccountAuthorizedUserCredentials}. See {@link 103 * ExternalAccountAuthorizedUserCredentials.Builder} 104 */ ExternalAccountAuthorizedUserCredentials(Builder builder)105 private ExternalAccountAuthorizedUserCredentials(Builder builder) { 106 super(builder); 107 this.transportFactory = 108 MoreObjects.firstNonNull( 109 builder.transportFactory, 110 getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); 111 this.transportFactoryClassName = this.transportFactory.getClass().getName(); 112 this.audience = builder.audience; 113 this.refreshToken = builder.refreshToken; 114 this.tokenUrl = builder.tokenUrl; 115 this.tokenInfoUrl = builder.tokenInfoUrl; 116 this.revokeUrl = builder.revokeUrl; 117 this.clientId = builder.clientId; 118 this.clientSecret = builder.clientSecret; 119 120 Preconditions.checkState( 121 getAccessToken() != null || canRefresh(), 122 "ExternalAccountAuthorizedUserCredentials must be initialized with " 123 + "an access token or fields to enable refresh: " 124 + "('refresh_token', 'token_url', 'client_id', 'client_secret')."); 125 } 126 127 /** 128 * Returns external account authorized user credentials defined by a JSON file stream. 129 * 130 * @param credentialsStream the stream with the credential definition 131 * @return the credential defined by the credentialsStream 132 * @throws IOException if the credential cannot be created from the stream 133 */ fromStream(InputStream credentialsStream)134 public static ExternalAccountAuthorizedUserCredentials fromStream(InputStream credentialsStream) 135 throws IOException { 136 checkNotNull(credentialsStream); 137 return fromStream(credentialsStream, OAuth2Utils.HTTP_TRANSPORT_FACTORY); 138 } 139 140 /** 141 * Returns external account authorized user credentials defined by a JSON file stream. 142 * 143 * @param credentialsStream the stream with the credential definition 144 * @param transportFactory the HTTP transport factory used to create the transport to get access 145 * tokens 146 * @return the credential defined by the credentialsStream 147 * @throws IOException if the credential cannot be created from the stream 148 */ fromStream( InputStream credentialsStream, HttpTransportFactory transportFactory)149 public static ExternalAccountAuthorizedUserCredentials fromStream( 150 InputStream credentialsStream, HttpTransportFactory transportFactory) throws IOException { 151 checkNotNull(credentialsStream); 152 checkNotNull(transportFactory); 153 154 JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); 155 GenericJson fileContents = 156 parser.parseAndClose(credentialsStream, StandardCharsets.UTF_8, GenericJson.class); 157 try { 158 return fromJson(fileContents, transportFactory); 159 } catch (ClassCastException | IllegalArgumentException e) { 160 throw new CredentialFormatException("Invalid input stream provided.", e); 161 } 162 } 163 164 @Override refreshAccessToken()165 public AccessToken refreshAccessToken() throws IOException { 166 if (!canRefresh()) { 167 throw new IllegalStateException( 168 "Unable to refresh ExternalAccountAuthorizedUserCredentials. All of 'refresh_token'," 169 + "'token_url', 'client_id', 'client_secret' are required to refresh."); 170 } 171 172 HttpResponse response; 173 try { 174 HttpRequest httpRequest = buildRefreshRequest(); 175 response = httpRequest.execute(); 176 } catch (HttpResponseException e) { 177 throw OAuthException.createFromHttpResponseException(e); 178 } 179 180 // Parse response. 181 GenericData responseData = response.parseAs(GenericData.class); 182 response.disconnect(); 183 184 // Required fields. 185 String accessToken = 186 OAuth2Utils.validateString(responseData, /* key= */ "access_token", PARSE_ERROR_PREFIX); 187 int expiresInSeconds = 188 OAuth2Utils.validateInt32(responseData, /* key= */ "expires_in", PARSE_ERROR_PREFIX); 189 Date expiresAtMilliseconds = new Date(clock.currentTimeMillis() + expiresInSeconds * 1000L); 190 191 // Set the new refresh token if returned. 192 String refreshToken = 193 OAuth2Utils.validateOptionalString( 194 responseData, /* key= */ "refresh_token", PARSE_ERROR_PREFIX); 195 if (refreshToken != null && refreshToken.trim().length() > 0) { 196 this.refreshToken = refreshToken; 197 } 198 199 return AccessToken.newBuilder() 200 .setExpirationTime(expiresAtMilliseconds) 201 .setTokenValue(accessToken) 202 .build(); 203 } 204 205 @Nullable getAudience()206 public String getAudience() { 207 return audience; 208 } 209 210 @Nullable getClientId()211 public String getClientId() { 212 return clientId; 213 } 214 215 @Nullable getClientSecret()216 public String getClientSecret() { 217 return clientSecret; 218 } 219 220 @Nullable getRevokeUrl()221 public String getRevokeUrl() { 222 return revokeUrl; 223 } 224 225 @Nullable getTokenUrl()226 public String getTokenUrl() { 227 return tokenUrl; 228 } 229 230 @Nullable getTokenInfoUrl()231 public String getTokenInfoUrl() { 232 return tokenInfoUrl; 233 } 234 235 @Nullable getRefreshToken()236 public String getRefreshToken() { 237 return refreshToken; 238 } 239 newBuilder()240 public static Builder newBuilder() { 241 return new Builder(); 242 } 243 244 @Override hashCode()245 public int hashCode() { 246 return Objects.hash( 247 super.hashCode(), 248 getAccessToken(), 249 clientId, 250 clientSecret, 251 refreshToken, 252 tokenUrl, 253 tokenInfoUrl, 254 revokeUrl, 255 audience, 256 transportFactoryClassName, 257 quotaProjectId); 258 } 259 260 @Override toString()261 public String toString() { 262 return MoreObjects.toStringHelper(this) 263 .add("requestMetadata", getRequestMetadataInternal()) 264 .add("temporaryAccess", getAccessToken()) 265 .add("clientId", clientId) 266 .add("clientSecret", clientSecret) 267 .add("refreshToken", refreshToken) 268 .add("tokenUrl", tokenUrl) 269 .add("tokenInfoUrl", tokenInfoUrl) 270 .add("revokeUrl", revokeUrl) 271 .add("audience", audience) 272 .add("transportFactoryClassName", transportFactoryClassName) 273 .add("quotaProjectId", quotaProjectId) 274 .toString(); 275 } 276 277 @Override equals(Object obj)278 public boolean equals(Object obj) { 279 if (!(obj instanceof ExternalAccountAuthorizedUserCredentials)) { 280 return false; 281 } 282 ExternalAccountAuthorizedUserCredentials credentials = 283 (ExternalAccountAuthorizedUserCredentials) obj; 284 return super.equals(credentials) 285 && Objects.equals(this.getAccessToken(), credentials.getAccessToken()) 286 && Objects.equals(this.clientId, credentials.clientId) 287 && Objects.equals(this.clientSecret, credentials.clientSecret) 288 && Objects.equals(this.refreshToken, credentials.refreshToken) 289 && Objects.equals(this.tokenUrl, credentials.tokenUrl) 290 && Objects.equals(this.tokenInfoUrl, credentials.tokenInfoUrl) 291 && Objects.equals(this.revokeUrl, credentials.revokeUrl) 292 && Objects.equals(this.audience, credentials.audience) 293 && Objects.equals(this.transportFactoryClassName, credentials.transportFactoryClassName) 294 && Objects.equals(this.quotaProjectId, credentials.quotaProjectId); 295 } 296 297 @Override toBuilder()298 public Builder toBuilder() { 299 return new Builder(this); 300 } 301 302 /** 303 * Returns external account authorized user credentials defined by JSON contents using the format 304 * supported by the Cloud SDK. 305 * 306 * @param json a map from the JSON representing the credentials 307 * @param transportFactory HTTP transport factory, creates the transport used to get access tokens 308 * @return the external account authorized user credentials defined by the JSON 309 */ fromJson( Map<String, Object> json, HttpTransportFactory transportFactory)310 static ExternalAccountAuthorizedUserCredentials fromJson( 311 Map<String, Object> json, HttpTransportFactory transportFactory) throws IOException { 312 String audience = (String) json.get("audience"); 313 String refreshToken = (String) json.get("refresh_token"); 314 String tokenUrl = (String) json.get("token_url"); 315 String tokenInfoUrl = (String) json.get("token_info_url"); 316 String revokeUrl = (String) json.get("revoke_url"); 317 String clientId = (String) json.get("client_id"); 318 String clientSecret = (String) json.get("client_secret"); 319 String quotaProjectId = (String) json.get("quota_project_id"); 320 String universeDomain = (String) json.get("universe_domain"); 321 322 return ExternalAccountAuthorizedUserCredentials.newBuilder() 323 .setAudience(audience) 324 .setRefreshToken(refreshToken) 325 .setTokenUrl(tokenUrl) 326 .setTokenInfoUrl(tokenInfoUrl) 327 .setRevokeUrl(revokeUrl) 328 .setClientId(clientId) 329 .setClientSecret(clientSecret) 330 .setRefreshToken(refreshToken) 331 .setHttpTransportFactory(transportFactory) 332 .setQuotaProjectId(quotaProjectId) 333 .setUniverseDomain(universeDomain) 334 .build(); 335 } 336 readObject(ObjectInputStream input)337 private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { 338 input.defaultReadObject(); 339 transportFactory = newInstance(transportFactoryClassName); 340 } 341 canRefresh()342 private boolean canRefresh() { 343 return refreshToken != null 344 && refreshToken.trim().length() > 0 345 && tokenUrl != null 346 && tokenUrl.trim().length() > 0 347 && clientId != null 348 && clientId.trim().length() > 0 349 && clientSecret != null 350 && clientSecret.trim().length() > 0; 351 } 352 buildRefreshRequest()353 private HttpRequest buildRefreshRequest() throws IOException { 354 GenericData tokenRequest = new GenericData(); 355 tokenRequest.set("grant_type", "refresh_token"); 356 tokenRequest.set("refresh_token", refreshToken); 357 358 HttpRequest request = 359 transportFactory 360 .create() 361 .createRequestFactory() 362 .buildPostRequest(new GenericUrl(tokenUrl), new UrlEncodedContent(tokenRequest)); 363 364 request.setParser(new JsonObjectParser(JSON_FACTORY)); 365 366 HttpHeaders requestHeaders = request.getHeaders(); 367 requestHeaders.setAuthorization( 368 String.format( 369 "Basic %s", 370 BaseEncoding.base64() 371 .encode( 372 String.format("%s:%s", clientId, clientSecret) 373 .getBytes(StandardCharsets.UTF_8)))); 374 375 return request; 376 } 377 378 /** Builder for {@link ExternalAccountAuthorizedUserCredentials}. */ 379 public static class Builder extends GoogleCredentials.Builder { 380 381 private HttpTransportFactory transportFactory; 382 private String audience; 383 private String refreshToken; 384 private String tokenUrl; 385 private String tokenInfoUrl; 386 private String revokeUrl; 387 private String clientId; 388 private String clientSecret; 389 Builder()390 protected Builder() {} 391 Builder(ExternalAccountAuthorizedUserCredentials credentials)392 protected Builder(ExternalAccountAuthorizedUserCredentials credentials) { 393 super(credentials); 394 this.transportFactory = credentials.transportFactory; 395 this.audience = credentials.audience; 396 this.refreshToken = credentials.refreshToken; 397 this.tokenUrl = credentials.tokenUrl; 398 this.tokenInfoUrl = credentials.tokenInfoUrl; 399 this.revokeUrl = credentials.revokeUrl; 400 this.clientId = credentials.clientId; 401 this.clientSecret = credentials.clientSecret; 402 } 403 404 /** 405 * Sets the HTTP transport factory. 406 * 407 * @param transportFactory the {@code HttpTransportFactory} to set 408 * @return this {@code Builder} object 409 */ 410 @CanIgnoreReturnValue setHttpTransportFactory(HttpTransportFactory transportFactory)411 public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { 412 this.transportFactory = transportFactory; 413 return this; 414 } 415 416 /** 417 * Sets the optional audience, which is usually the fully specified resource name of the 418 * workforce pool provider. 419 * 420 * @param audience the audience to set 421 * @return this {@code Builder} object 422 */ 423 @CanIgnoreReturnValue setAudience(String audience)424 public Builder setAudience(String audience) { 425 this.audience = audience; 426 return this; 427 } 428 429 /** 430 * Sets the token exchange endpoint. 431 * 432 * @param tokenUrl the token exchange url to set 433 * @return this {@code Builder} object 434 */ 435 @CanIgnoreReturnValue setTokenUrl(String tokenUrl)436 public Builder setTokenUrl(String tokenUrl) { 437 this.tokenUrl = tokenUrl; 438 return this; 439 } 440 441 /** 442 * Sets the token introspection endpoint used to retrieve account related information. 443 * 444 * @param tokenInfoUrl the token info url to set 445 * @return this {@code Builder} object 446 */ 447 @CanIgnoreReturnValue setTokenInfoUrl(String tokenInfoUrl)448 public Builder setTokenInfoUrl(String tokenInfoUrl) { 449 this.tokenInfoUrl = tokenInfoUrl; 450 return this; 451 } 452 453 /** 454 * Sets the token revocation endpoint. 455 * 456 * @param revokeUrl the revoke url to set 457 * @return this {@code Builder} object 458 */ 459 @CanIgnoreReturnValue setRevokeUrl(String revokeUrl)460 public Builder setRevokeUrl(String revokeUrl) { 461 this.revokeUrl = revokeUrl; 462 return this; 463 } 464 465 /** 466 * Sets the OAuth 2.0 refresh token. 467 * 468 * @param refreshToken the refresh token 469 * @return this {@code Builder} object 470 */ 471 @CanIgnoreReturnValue setRefreshToken(String refreshToken)472 public Builder setRefreshToken(String refreshToken) { 473 this.refreshToken = refreshToken; 474 return this; 475 } 476 477 /** 478 * Sets the OAuth 2.0 client ID. 479 * 480 * @param clientId the client ID 481 * @return this {@code Builder} object 482 */ 483 @CanIgnoreReturnValue setClientId(String clientId)484 public Builder setClientId(String clientId) { 485 this.clientId = clientId; 486 return this; 487 } 488 489 /** 490 * Sets the OAuth 2.0 client secret. 491 * 492 * @param clientSecret the client secret 493 * @return this {@code Builder} object 494 */ 495 @CanIgnoreReturnValue setClientSecret(String clientSecret)496 public Builder setClientSecret(String clientSecret) { 497 this.clientSecret = clientSecret; 498 return this; 499 } 500 501 /** 502 * Sets the optional project used for quota and billing purposes. 503 * 504 * @param quotaProjectId the quota and billing project id to set 505 * @return this {@code Builder} object 506 */ 507 @Override 508 @CanIgnoreReturnValue setQuotaProjectId(String quotaProjectId)509 public Builder setQuotaProjectId(String quotaProjectId) { 510 super.setQuotaProjectId(quotaProjectId); 511 return this; 512 } 513 514 /** 515 * Sets the optional access token. 516 * 517 * @param accessToken the access token 518 * @return this {@code Builder} object 519 */ 520 @Override 521 @CanIgnoreReturnValue setAccessToken(AccessToken accessToken)522 public Builder setAccessToken(AccessToken accessToken) { 523 super.setAccessToken(accessToken); 524 return this; 525 } 526 527 /** 528 * Sets the optional universe domain. The Google Default Universe is used when not provided. 529 * 530 * @param universeDomain the universe domain to set 531 * @return this {@code Builder} object 532 */ 533 @CanIgnoreReturnValue 534 @Override setUniverseDomain(String universeDomain)535 public Builder setUniverseDomain(String universeDomain) { 536 super.setUniverseDomain(universeDomain); 537 return this; 538 } 539 540 @Override build()541 public ExternalAccountAuthorizedUserCredentials build() { 542 return new ExternalAccountAuthorizedUserCredentials(this); 543 } 544 } 545 } 546