Skip to content
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
/*
* Copyright 2024, Google LLC
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
*
* * Neither the name of Google LLC nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/

package com.google.auth.credentialaccessboundary;

import static com.google.auth.oauth2.OAuth2Credentials.getFromServiceLoader;
import static com.google.auth.oauth2.OAuth2Utils.TOKEN_EXCHANGE_URL_FORMAT;
import static com.google.common.base.Preconditions.checkNotNull;

import com.google.auth.Credentials;
import com.google.auth.http.HttpTransportFactory;
import com.google.auth.oauth2.AccessToken;
import com.google.auth.oauth2.CredentialAccessBoundary;
import com.google.auth.oauth2.GoogleCredentials;
import com.google.auth.oauth2.OAuth2Utils;
import com.google.auth.oauth2.StsRequestHandler;
import com.google.auth.oauth2.StsTokenExchangeRequest;
import com.google.auth.oauth2.StsTokenExchangeResponse;
import com.google.common.base.Strings;
import com.google.errorprone.annotations.CanIgnoreReturnValue;
import java.io.IOException;

public final class ClientSideCredentialAccessBoundaryFactory {
private final GoogleCredentials sourceCredential;
private final transient HttpTransportFactory transportFactory;
private final String tokenExchangeEndpoint;
private String accessBoundarySessionKey;
private AccessToken intermediateAccessToken;

private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
this.transportFactory = builder.transportFactory;
this.sourceCredential = builder.sourceCredential;
this.tokenExchangeEndpoint = builder.tokenExchangeEndpoint;
}

/**
* Refreshes the source credential and exchanges it for an intermediary access token using the STS
* endpoint.
*
* <p>If the source credential is expired, it will be refreshed. A token exchange request is then
* made to the STS endpoint. The resulting intermediary access token and access boundary session
* key are stored. The intermediary access token's expiration time is determined as follows:
*
* <ol>
* <li>If the STS response includes `expires_in`, that value is used.
* <li>Otherwise, if the source credential has an expiration time, that value is used.
* <li>Otherwise, the intermediary token will have no expiration time.
* </ol>
*
* @throws IOException If an error occurs during credential refresh or token exchange.
*/
private void refreshCredentials() throws IOException {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you be able to write some quick comments that describes some of the behavior regarding the source access token and the response access token and how they form the intermediate access token?

From what I gather from the code, but it would be helpful for a comment to help confirm:
Intermediate access token is usually the STS response, unless the response doesn't have an expiration time. If not, then a new access token is created with the source access token's expiration time unless that value also doesn't exist.

What happens when both access tokens don't have an expiration time? Will this end up failing downstream? Will this just live forever?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added documentation to the function. Please let me know if additional clarification is needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that makes sense! Is there any issue if the intermediary token doesn't have an expiration time?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe the intermediary token should still work as expected without any issues even if there's no expiration time.

Copy link
Member

@lqiu96 lqiu96 Nov 14, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good, would it be possible confirm that? If does end up failing, could you document that or link to docs that detail that?

try {
this.sourceCredential.refreshIfExpired();
} catch (IOException e) {
throw new IOException("Unable to refresh the provided source credential.", e);
}
Comment on lines +81 to +85
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you remind me why we are re-throwing this exception? If there is no reason, I think we should have refreshIfExpired() throw the exception instead of catching and then re-throw

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From @aeitzman: It's just to give more easily readable details about what part of the auth flow broke vs just returning the stack trace. I don't think its strictly necessary but it makes the library easier to debug.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I'm fine with this for now, but I'd rather have this be captured via logs (that is probably a separate project).

CC: @zhumin8


AccessToken sourceAccessToken = sourceCredential.getAccessToken();
if (sourceAccessToken == null || Strings.isNullOrEmpty(sourceAccessToken.getTokenValue())) {
throw new IllegalStateException("The source credential does not have an access token.");
}

StsTokenExchangeRequest request =
StsTokenExchangeRequest.newBuilder(
sourceAccessToken.getTokenValue(), OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN)
.setRequestTokenType(OAuth2Utils.TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN)
.build();

StsRequestHandler handler =
StsRequestHandler.newBuilder(
tokenExchangeEndpoint, request, transportFactory.create().createRequestFactory())
.build();

StsTokenExchangeResponse response = handler.exchangeToken();
this.accessBoundarySessionKey = response.getAccessBoundarySessionKey();
this.intermediateAccessToken = response.getAccessToken();

// The STS endpoint will only return the expiration time for the intermediary token
// if the original access token represents a service account.
// The intermediary token's expiration time will always match the source credential expiration.
// When no expires_in is returned, we can copy the source credential's expiration time.
if (response.getAccessToken().getExpirationTime() == null) {
if (sourceAccessToken.getExpirationTime() != null) {
this.intermediateAccessToken =
new AccessToken(
response.getAccessToken().getTokenValue(), sourceAccessToken.getExpirationTime());
}
}
}

private void refreshCredentialsIfRequired() {
// TODO(negarb): Implement refreshCredentialsIfRequired
throw new UnsupportedOperationException("refreshCredentialsIfRequired is not yet implemented.");
}

public AccessToken generateToken(CredentialAccessBoundary accessBoundary) {
// TODO(negarb/jiahuah): Implement generateToken
throw new UnsupportedOperationException("generateToken is not yet implemented.");
}

public static Builder newBuilder() {
return new Builder();
}

public static class Builder {
private GoogleCredentials sourceCredential;
private HttpTransportFactory transportFactory;
private String universeDomain;
private String tokenExchangeEndpoint;

private Builder() {}

/**
* Sets the required source credential.
*
* @param sourceCredential the {@code GoogleCredentials} to set
* @return this {@code Builder} object
*/
@CanIgnoreReturnValue
public Builder setSourceCredential(GoogleCredentials sourceCredential) {
this.sourceCredential = sourceCredential;
return this;
}

/**
* Sets the HTTP transport factory.
*
* @param transportFactory the {@code HttpTransportFactory} to set
* @return this {@code Builder} object
*/
@CanIgnoreReturnValue
public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
this.transportFactory = transportFactory;
return this;
}

/**
* Sets the optional universe domain.
*
* @param universeDomain the universe domain to set
* @return this {@code Builder} object
*/
@CanIgnoreReturnValue
public Builder setUniverseDomain(String universeDomain) {
this.universeDomain = universeDomain;
return this;
}

public ClientSideCredentialAccessBoundaryFactory build() {
checkNotNull(sourceCredential, "Source credential must not be null.");

// Use the default HTTP transport factory if none was provided.
if (transportFactory == null) {
this.transportFactory =
getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
}

// Default to GDU when not supplied.
if (Strings.isNullOrEmpty(universeDomain)) {
this.universeDomain = Credentials.GOOGLE_DEFAULT_UNIVERSE;
}

// Ensure source credential's universe domain matches.
try {
if (!universeDomain.equals(sourceCredential.getUniverseDomain())) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qq, does CAB plan to restrict the sourceCredentials to a subset of GoogleCredentials (i.e. only Compute or SA)?

Or can any GoogleCredential be used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently, client-side CAB only works with service account tokens. In the future we might add support for other types of tokens.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it would be helpful to add validation for that. One reason is that universe_domain retrieval should never throw an IOException for SACredentials. It was added for ComputeEngineCredentials because of MDS.

Also it could be helpful to get an error message earlier, rather than finding out when trying to refreshCredentials

throw new IllegalArgumentException(
"The client side access boundary credential's universe domain must be the same as the source "
+ "credential.");
}
} catch (IOException e) {
// Throwing an IOException would be a breaking change, so wrap it here.
throw new IllegalStateException(
"Error occurred when attempting to retrieve source credential universe domain.", e);
}

this.tokenExchangeEndpoint = String.format(TOKEN_EXCHANGE_URL_FORMAT, universeDomain);
return new ClientSideCredentialAccessBoundaryFactory(this);
}
}
}
42 changes: 42 additions & 0 deletions cab-token-generator/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-parent</artifactId>
<version>1.29.1-SNAPSHOT
</version><!-- {x-version-update:google-auth-library-parent:current} -->
Comment on lines +9 to +10
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<version>1.29.1-SNAPSHOT
</version><!-- {x-version-update:google-auth-library-parent:current} -->
<version>1.30.0</version><!-- {x-version-update:google-auth-library-parent:current} -->

This version is going to change as we cut new releases of auth. Last thing before we merge will be to update this to the latest version.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll leave this comment open as a reminder to update the version before the feature is merged to the main branch.

</parent>

<artifactId>cab-token-generator</artifactId>
<name>Google Auth Library for Java - Cab Token Generator</name>

<build>
<sourceDirectory>java</sourceDirectory>
</build>
<dependencies>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-oauth2-http</artifactId>
</dependency>
<dependency>
<groupId>com.google.auth</groupId>
<artifactId>google-auth-library-credentials</artifactId>
</dependency>
<dependency>
<groupId>com.google.http-client</groupId>
<artifactId>google-http-client</artifactId>
</dependency>
<dependency>
<groupId>com.google.errorprone</groupId>
<artifactId>error_prone_annotations</artifactId>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</dependency>
</dependencies>

</project>
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@

package com.google.auth.oauth2;

import static com.google.auth.oauth2.OAuth2Utils.TOKEN_EXCHANGE_URL_FORMAT;
import static com.google.common.base.MoreObjects.firstNonNull;
import static com.google.common.base.Preconditions.checkNotNull;

Expand Down Expand Up @@ -88,7 +89,6 @@
*/
public final class DownscopedCredentials extends OAuth2Credentials {

private final String TOKEN_EXCHANGE_URL_FORMAT = "https://sts.{universe_domain}/v1/token";
private final GoogleCredentials sourceCredential;
private final CredentialAccessBoundary credentialAccessBoundary;
private final String universeDomain;
Expand Down Expand Up @@ -125,8 +125,7 @@ private DownscopedCredentials(Builder builder) {
throw new IllegalStateException(
"Error occurred when attempting to retrieve source credential universe domain.", e);
}
this.tokenExchangeEndpoint =
TOKEN_EXCHANGE_URL_FORMAT.replace("{universe_domain}", universeDomain);
this.tokenExchangeEndpoint = String.format(TOKEN_EXCHANGE_URL_FORMAT, universeDomain);
}

@Override
Expand Down
11 changes: 10 additions & 1 deletion oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java
Original file line number Diff line number Diff line change
Expand Up @@ -484,7 +484,16 @@ protected static <T> T newInstance(String className) throws IOException, ClassNo
}
}

protected static <T> T getFromServiceLoader(Class<? extends T> clazz, T defaultInstance) {
/**
* Returns the first service provider from the given service loader.
*
* @param clazz The class of the service provider to load.
* @param defaultInstance The default instance to return if no service providers are found.
* @param <T> The type of the service provider.
* @return The first service provider from the service loader, or the {@code defaultInstance} if
* no service providers are found.
*/
public static <T> T getFromServiceLoader(Class<? extends T> clazz, T defaultInstance) {
return Iterables.getFirst(ServiceLoader.load(clazz), defaultInstance);
}

Expand Down
12 changes: 9 additions & 3 deletions oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@
import java.util.Set;

/** Internal utilities for the com.google.auth.oauth2 namespace. */
class OAuth2Utils {
public class OAuth2Utils {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure if it makes sense for this to be public @lqiu96

Copy link
Member

@lqiu96 lqiu96 Nov 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One option for this to see if we can keep this package-private and move some of these constants to STSRequestHandler. It looks like TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN and TOKEN_EXCHANGE_URL_FORMAT are new additions could live in the individual classes (i.e StsREquestHandler or DownscopedCredentials).

TOKEN_TYPE_ACCESS_TOKEN is used in a few places, but if it makes sense we can move them around. I don't know if TOKEN_TYPE_ACCESS_TOKEN` applies to non-CAB use cases/ if we rather keep it in this Utils class.

Otherwise, I think I'm ok with making it public given the module constraints we have.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm going to leave this comment open and keep it as-is for now so I can merge this PR and unblock #1571. I'll address this comment in the next PR.


static final String SIGNATURE_ALGORITHM = "SHA256withRSA";

static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
public static final String TOKEN_TYPE_ACCESS_TOKEN =
"urn:ietf:params:oauth:token-type:access_token";
static final String TOKEN_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:token-type:token-exchange";
public static final String TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN =
"urn:ietf:params:oauth:token-type:access_boundary_intermediary_token";
static final String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";

// generateIdToken endpoint is to be formatted with universe domain and client email
Expand All @@ -85,6 +89,7 @@ class OAuth2Utils {
"https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:generateAccessToken";
static final String SIGN_BLOB_ENDPOINT_FORMAT =
"https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:signBlob";
public static final String TOKEN_EXCHANGE_URL_FORMAT = "https://sts.%s/v1/token";

static final URI TOKEN_SERVER_URI = URI.create("https://oauth2.googleapis.com/token");

Expand All @@ -93,7 +98,8 @@ class OAuth2Utils {

static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();

static final HttpTransportFactory HTTP_TRANSPORT_FACTORY = new DefaultHttpTransportFactory();
public static final HttpTransportFactory HTTP_TRANSPORT_FACTORY =
new DefaultHttpTransportFactory();

static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();

Expand Down
27 changes: 25 additions & 2 deletions oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,18 @@
import java.util.List;
import javax.annotation.Nullable;

/** Implements the OAuth 2.0 token exchange based on https://tools.ietf.org/html/rfc8693. */
final class StsRequestHandler {
/**
* Implements the OAuth 2.0 token exchange based on <a
* href="https://tools.ietf.org/html/rfc8693">RFC 8693</a>.
*
* <p>This class handles the process of exchanging one type of token for another using the Security
* Token Service (STS). It constructs and sends the token exchange request to the STS endpoint and
* parses the response to create an {@link StsTokenExchangeResponse} object.
*
* <p>Use the {@link #newBuilder(String, StsTokenExchangeRequest, HttpRequestFactory)} method to
* create a new builder for constructing an instance of this class.
*/
public final class StsRequestHandler {
private static final String TOKEN_EXCHANGE_GRANT_TYPE =
"urn:ietf:params:oauth:grant-type:token-exchange";
private static final String PARSE_ERROR_PREFIX = "Error parsing token response.";
Expand Down Expand Up @@ -85,6 +95,14 @@ private StsRequestHandler(
this.internalOptions = internalOptions;
}

/**
* Returns a new builder for creating an instance of {@link StsRequestHandler}.
*
* @param tokenExchangeEndpoint The STS token exchange endpoint.
* @param stsTokenExchangeRequest The STS token exchange request.
* @param httpRequestFactory The HTTP request factory to use for sending the request.
* @return A new builder instance.
*/
public static Builder newBuilder(
String tokenExchangeEndpoint,
StsTokenExchangeRequest stsTokenExchangeRequest,
Expand Down Expand Up @@ -175,6 +193,11 @@ private StsTokenExchangeResponse buildResponse(GenericData responseData) throws
String scope = OAuth2Utils.validateString(responseData, "scope", PARSE_ERROR_PREFIX);
builder.setScopes(Arrays.asList(scope.trim().split("\\s+")));
}
if (responseData.containsKey("access_boundary_session_key")) {
builder.setAccessBoundarySessionKey(
OAuth2Utils.validateString(
responseData, "access_boundary_session_key", PARSE_ERROR_PREFIX));
}
return builder.build();
}

Expand Down
Loading