Skip to content

Commit 5d32642

Browse files
authored
feat: Implement ClientSideCredentialAccessBoundaryFactory (#1562)
* feat: Implement ClientSideCredentialAccessBoundaryFactory.refreshCredentials() Set up the ClientSideCredentialAccessBoundaryFactory class and module. Implement the function to fetch and refresh intermediary tokens from STS.
1 parent f33d84c commit 5d32642

File tree

12 files changed

+407
-18
lines changed

12 files changed

+407
-18
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2024, 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.credentialaccessboundary;
33+
34+
import static com.google.auth.oauth2.OAuth2Credentials.getFromServiceLoader;
35+
import static com.google.auth.oauth2.OAuth2Utils.TOKEN_EXCHANGE_URL_FORMAT;
36+
import static com.google.common.base.Preconditions.checkNotNull;
37+
38+
import com.google.auth.Credentials;
39+
import com.google.auth.http.HttpTransportFactory;
40+
import com.google.auth.oauth2.AccessToken;
41+
import com.google.auth.oauth2.CredentialAccessBoundary;
42+
import com.google.auth.oauth2.GoogleCredentials;
43+
import com.google.auth.oauth2.OAuth2Utils;
44+
import com.google.auth.oauth2.StsRequestHandler;
45+
import com.google.auth.oauth2.StsTokenExchangeRequest;
46+
import com.google.auth.oauth2.StsTokenExchangeResponse;
47+
import com.google.common.base.Strings;
48+
import com.google.errorprone.annotations.CanIgnoreReturnValue;
49+
import java.io.IOException;
50+
51+
public final class ClientSideCredentialAccessBoundaryFactory {
52+
private final GoogleCredentials sourceCredential;
53+
private final transient HttpTransportFactory transportFactory;
54+
private final String tokenExchangeEndpoint;
55+
private String accessBoundarySessionKey;
56+
private AccessToken intermediateAccessToken;
57+
58+
private ClientSideCredentialAccessBoundaryFactory(Builder builder) {
59+
this.transportFactory = builder.transportFactory;
60+
this.sourceCredential = builder.sourceCredential;
61+
this.tokenExchangeEndpoint = builder.tokenExchangeEndpoint;
62+
}
63+
64+
/**
65+
* Refreshes the source credential and exchanges it for an intermediary access token using the STS
66+
* endpoint.
67+
*
68+
* <p>If the source credential is expired, it will be refreshed. A token exchange request is then
69+
* made to the STS endpoint. The resulting intermediary access token and access boundary session
70+
* key are stored. The intermediary access token's expiration time is determined as follows:
71+
*
72+
* <ol>
73+
* <li>If the STS response includes `expires_in`, that value is used.
74+
* <li>Otherwise, if the source credential has an expiration time, that value is used.
75+
* <li>Otherwise, the intermediary token will have no expiration time.
76+
* </ol>
77+
*
78+
* @throws IOException If an error occurs during credential refresh or token exchange.
79+
*/
80+
private void refreshCredentials() throws IOException {
81+
try {
82+
this.sourceCredential.refreshIfExpired();
83+
} catch (IOException e) {
84+
throw new IOException("Unable to refresh the provided source credential.", e);
85+
}
86+
87+
AccessToken sourceAccessToken = sourceCredential.getAccessToken();
88+
if (sourceAccessToken == null || Strings.isNullOrEmpty(sourceAccessToken.getTokenValue())) {
89+
throw new IllegalStateException("The source credential does not have an access token.");
90+
}
91+
92+
StsTokenExchangeRequest request =
93+
StsTokenExchangeRequest.newBuilder(
94+
sourceAccessToken.getTokenValue(), OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN)
95+
.setRequestTokenType(OAuth2Utils.TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN)
96+
.build();
97+
98+
StsRequestHandler handler =
99+
StsRequestHandler.newBuilder(
100+
tokenExchangeEndpoint, request, transportFactory.create().createRequestFactory())
101+
.build();
102+
103+
StsTokenExchangeResponse response = handler.exchangeToken();
104+
this.accessBoundarySessionKey = response.getAccessBoundarySessionKey();
105+
this.intermediateAccessToken = response.getAccessToken();
106+
107+
// The STS endpoint will only return the expiration time for the intermediary token
108+
// if the original access token represents a service account.
109+
// The intermediary token's expiration time will always match the source credential expiration.
110+
// When no expires_in is returned, we can copy the source credential's expiration time.
111+
if (response.getAccessToken().getExpirationTime() == null) {
112+
if (sourceAccessToken.getExpirationTime() != null) {
113+
this.intermediateAccessToken =
114+
new AccessToken(
115+
response.getAccessToken().getTokenValue(), sourceAccessToken.getExpirationTime());
116+
}
117+
}
118+
}
119+
120+
private void refreshCredentialsIfRequired() {
121+
// TODO(negarb): Implement refreshCredentialsIfRequired
122+
throw new UnsupportedOperationException("refreshCredentialsIfRequired is not yet implemented.");
123+
}
124+
125+
public AccessToken generateToken(CredentialAccessBoundary accessBoundary) {
126+
// TODO(negarb/jiahuah): Implement generateToken
127+
throw new UnsupportedOperationException("generateToken is not yet implemented.");
128+
}
129+
130+
public static Builder newBuilder() {
131+
return new Builder();
132+
}
133+
134+
public static class Builder {
135+
private GoogleCredentials sourceCredential;
136+
private HttpTransportFactory transportFactory;
137+
private String universeDomain;
138+
private String tokenExchangeEndpoint;
139+
140+
private Builder() {}
141+
142+
/**
143+
* Sets the required source credential.
144+
*
145+
* @param sourceCredential the {@code GoogleCredentials} to set
146+
* @return this {@code Builder} object
147+
*/
148+
@CanIgnoreReturnValue
149+
public Builder setSourceCredential(GoogleCredentials sourceCredential) {
150+
this.sourceCredential = sourceCredential;
151+
return this;
152+
}
153+
154+
/**
155+
* Sets the HTTP transport factory.
156+
*
157+
* @param transportFactory the {@code HttpTransportFactory} to set
158+
* @return this {@code Builder} object
159+
*/
160+
@CanIgnoreReturnValue
161+
public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) {
162+
this.transportFactory = transportFactory;
163+
return this;
164+
}
165+
166+
/**
167+
* Sets the optional universe domain.
168+
*
169+
* @param universeDomain the universe domain to set
170+
* @return this {@code Builder} object
171+
*/
172+
@CanIgnoreReturnValue
173+
public Builder setUniverseDomain(String universeDomain) {
174+
this.universeDomain = universeDomain;
175+
return this;
176+
}
177+
178+
public ClientSideCredentialAccessBoundaryFactory build() {
179+
checkNotNull(sourceCredential, "Source credential must not be null.");
180+
181+
// Use the default HTTP transport factory if none was provided.
182+
if (transportFactory == null) {
183+
this.transportFactory =
184+
getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY);
185+
}
186+
187+
// Default to GDU when not supplied.
188+
if (Strings.isNullOrEmpty(universeDomain)) {
189+
this.universeDomain = Credentials.GOOGLE_DEFAULT_UNIVERSE;
190+
}
191+
192+
// Ensure source credential's universe domain matches.
193+
try {
194+
if (!universeDomain.equals(sourceCredential.getUniverseDomain())) {
195+
throw new IllegalArgumentException(
196+
"The client side access boundary credential's universe domain must be the same as the source "
197+
+ "credential.");
198+
}
199+
} catch (IOException e) {
200+
// Throwing an IOException would be a breaking change, so wrap it here.
201+
throw new IllegalStateException(
202+
"Error occurred when attempting to retrieve source credential universe domain.", e);
203+
}
204+
205+
this.tokenExchangeEndpoint = String.format(TOKEN_EXCHANGE_URL_FORMAT, universeDomain);
206+
return new ClientSideCredentialAccessBoundaryFactory(this);
207+
}
208+
}
209+
}

cab-token-generator/pom.xml

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<project xmlns="http://maven.apache.org/POM/4.0.0"
3+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
4+
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
5+
<modelVersion>4.0.0</modelVersion>
6+
<parent>
7+
<groupId>com.google.auth</groupId>
8+
<artifactId>google-auth-library-parent</artifactId>
9+
<version>1.29.1-SNAPSHOT
10+
</version><!-- {x-version-update:google-auth-library-parent:current} -->
11+
</parent>
12+
13+
<artifactId>cab-token-generator</artifactId>
14+
<name>Google Auth Library for Java - Cab Token Generator</name>
15+
16+
<build>
17+
<sourceDirectory>java</sourceDirectory>
18+
</build>
19+
<dependencies>
20+
<dependency>
21+
<groupId>com.google.auth</groupId>
22+
<artifactId>google-auth-library-oauth2-http</artifactId>
23+
</dependency>
24+
<dependency>
25+
<groupId>com.google.auth</groupId>
26+
<artifactId>google-auth-library-credentials</artifactId>
27+
</dependency>
28+
<dependency>
29+
<groupId>com.google.http-client</groupId>
30+
<artifactId>google-http-client</artifactId>
31+
</dependency>
32+
<dependency>
33+
<groupId>com.google.errorprone</groupId>
34+
<artifactId>error_prone_annotations</artifactId>
35+
</dependency>
36+
<dependency>
37+
<groupId>com.google.guava</groupId>
38+
<artifactId>guava</artifactId>
39+
</dependency>
40+
</dependencies>
41+
42+
</project>

oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
package com.google.auth.oauth2;
3333

34+
import static com.google.auth.oauth2.OAuth2Utils.TOKEN_EXCHANGE_URL_FORMAT;
3435
import static com.google.common.base.MoreObjects.firstNonNull;
3536
import static com.google.common.base.Preconditions.checkNotNull;
3637

@@ -88,7 +89,6 @@
8889
*/
8990
public final class DownscopedCredentials extends OAuth2Credentials {
9091

91-
private final String TOKEN_EXCHANGE_URL_FORMAT = "https://sts.{universe_domain}/v1/token";
9292
private final GoogleCredentials sourceCredential;
9393
private final CredentialAccessBoundary credentialAccessBoundary;
9494
private final String universeDomain;
@@ -125,8 +125,7 @@ private DownscopedCredentials(Builder builder) {
125125
throw new IllegalStateException(
126126
"Error occurred when attempting to retrieve source credential universe domain.", e);
127127
}
128-
this.tokenExchangeEndpoint =
129-
TOKEN_EXCHANGE_URL_FORMAT.replace("{universe_domain}", universeDomain);
128+
this.tokenExchangeEndpoint = String.format(TOKEN_EXCHANGE_URL_FORMAT, universeDomain);
130129
}
131130

132131
@Override

oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,16 @@ protected static <T> T newInstance(String className) throws IOException, ClassNo
484484
}
485485
}
486486

487-
protected static <T> T getFromServiceLoader(Class<? extends T> clazz, T defaultInstance) {
487+
/**
488+
* Returns the first service provider from the given service loader.
489+
*
490+
* @param clazz The class of the service provider to load.
491+
* @param defaultInstance The default instance to return if no service providers are found.
492+
* @param <T> The type of the service provider.
493+
* @return The first service provider from the service loader, or the {@code defaultInstance} if
494+
* no service providers are found.
495+
*/
496+
public static <T> T getFromServiceLoader(Class<? extends T> clazz, T defaultInstance) {
488497
return Iterables.getFirst(ServiceLoader.load(clazz), defaultInstance);
489498
}
490499

oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -70,11 +70,15 @@
7070
import java.util.Set;
7171

7272
/** Internal utilities for the com.google.auth.oauth2 namespace. */
73-
class OAuth2Utils {
73+
public class OAuth2Utils {
74+
7475
static final String SIGNATURE_ALGORITHM = "SHA256withRSA";
7576

76-
static final String TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token";
77+
public static final String TOKEN_TYPE_ACCESS_TOKEN =
78+
"urn:ietf:params:oauth:token-type:access_token";
7779
static final String TOKEN_TYPE_TOKEN_EXCHANGE = "urn:ietf:params:oauth:token-type:token-exchange";
80+
public static final String TOKEN_TYPE_ACCESS_BOUNDARY_INTERMEDIARY_TOKEN =
81+
"urn:ietf:params:oauth:token-type:access_boundary_intermediary_token";
7882
static final String GRANT_TYPE_JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
7983

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

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

@@ -93,7 +98,8 @@ class OAuth2Utils {
9398

9499
static final HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
95100

96-
static final HttpTransportFactory HTTP_TRANSPORT_FACTORY = new DefaultHttpTransportFactory();
101+
public static final HttpTransportFactory HTTP_TRANSPORT_FACTORY =
102+
new DefaultHttpTransportFactory();
97103

98104
static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
99105

oauth2_http/java/com/google/auth/oauth2/StsRequestHandler.java

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,18 @@
5050
import java.util.List;
5151
import javax.annotation.Nullable;
5252

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

98+
/**
99+
* Returns a new builder for creating an instance of {@link StsRequestHandler}.
100+
*
101+
* @param tokenExchangeEndpoint The STS token exchange endpoint.
102+
* @param stsTokenExchangeRequest The STS token exchange request.
103+
* @param httpRequestFactory The HTTP request factory to use for sending the request.
104+
* @return A new builder instance.
105+
*/
88106
public static Builder newBuilder(
89107
String tokenExchangeEndpoint,
90108
StsTokenExchangeRequest stsTokenExchangeRequest,
@@ -175,6 +193,11 @@ private StsTokenExchangeResponse buildResponse(GenericData responseData) throws
175193
String scope = OAuth2Utils.validateString(responseData, "scope", PARSE_ERROR_PREFIX);
176194
builder.setScopes(Arrays.asList(scope.trim().split("\\s+")));
177195
}
196+
if (responseData.containsKey("access_boundary_session_key")) {
197+
builder.setAccessBoundarySessionKey(
198+
OAuth2Utils.validateString(
199+
responseData, "access_boundary_session_key", PARSE_ERROR_PREFIX));
200+
}
178201
return builder.build();
179202
}
180203

0 commit comments

Comments
 (0)