Skip to content

Commit f4980c7

Browse files
feat: allow scopes for self signed jwt (#689)
* feat: self signed jwt support * update * address comments * allow to use uri as audience * address comments
1 parent dfe118c commit f4980c7

File tree

6 files changed

+230
-94
lines changed

6 files changed

+230
-94
lines changed

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,9 @@ public JwtClaims merge(JwtClaims other) {
106106
* @return true if all required fields have been set; false otherwise
107107
*/
108108
public boolean isComplete() {
109-
return getAudience() != null && getIssuer() != null && getSubject() != null;
109+
boolean hasScopes =
110+
getAdditionalClaims().containsKey("scope") && !getAdditionalClaims().get("scope").isEmpty();
111+
return (getAudience() != null || hasScopes) && getIssuer() != null && getSubject() != null;
110112
}
111113

112114
@AutoValue.Builder

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

Lines changed: 114 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
import java.security.spec.InvalidKeySpecException;
7878
import java.security.spec.PKCS8EncodedKeySpec;
7979
import java.util.Collection;
80+
import java.util.Collections;
8081
import java.util.Date;
8182
import java.util.List;
8283
import java.util.Map;
@@ -109,9 +110,9 @@ public class ServiceAccountCredentials extends GoogleCredentials
109110
private final Collection<String> defaultScopes;
110111
private final String quotaProjectId;
111112
private final int lifetime;
113+
private final boolean useJwtAccessWithScope;
112114

113115
private transient HttpTransportFactory transportFactory;
114-
private transient ServiceAccountJwtAccessCredentials jwtCredentials = null;
115116

116117
/**
117118
* Constructor with minimum identifying information and custom HTTP transport.
@@ -133,6 +134,7 @@ public class ServiceAccountCredentials extends GoogleCredentials
133134
* most 43200 (12 hours). If the token is used for calling a Google API, then the value should
134135
* be at most 3600 (1 hour). If the given value is 0, then the default value 3600 will be used
135136
* when creating the credentials.
137+
* @param useJwtAccessWithScope whether self signed JWT with scopes should be always used.
136138
*/
137139
ServiceAccountCredentials(
138140
String clientId,
@@ -146,7 +148,8 @@ public class ServiceAccountCredentials extends GoogleCredentials
146148
String serviceAccountUser,
147149
String projectId,
148150
String quotaProjectId,
149-
int lifetime) {
151+
int lifetime,
152+
boolean useJwtAccessWithScope) {
150153
this.clientId = clientId;
151154
this.clientEmail = Preconditions.checkNotNull(clientEmail);
152155
this.privateKey = Preconditions.checkNotNull(privateKey);
@@ -167,18 +170,7 @@ public class ServiceAccountCredentials extends GoogleCredentials
167170
throw new IllegalStateException("lifetime must be less than or equal to 43200");
168171
}
169172
this.lifetime = lifetime;
170-
171-
// Use self signed JWT if scopes is not set, see https://google.aip.dev/auth/4111.
172-
if (this.scopes.isEmpty()) {
173-
jwtCredentials =
174-
new ServiceAccountJwtAccessCredentials.Builder()
175-
.setClientEmail(clientEmail)
176-
.setClientId(clientId)
177-
.setPrivateKey(privateKey)
178-
.setPrivateKeyId(privateKeyId)
179-
.setQuotaProjectId(quotaProjectId)
180-
.build();
181-
}
173+
this.useJwtAccessWithScope = useJwtAccessWithScope;
182174
}
183175

184176
/**
@@ -492,7 +484,8 @@ static ServiceAccountCredentials fromPkcs8(
492484
serviceAccountUser,
493485
projectId,
494486
quotaProject,
495-
DEFAULT_LIFETIME_IN_SECONDS);
487+
DEFAULT_LIFETIME_IN_SECONDS,
488+
false);
496489
}
497490

498491
/** Helper to convert from a PKCS#8 String to an RSA private key */
@@ -698,7 +691,8 @@ public GoogleCredentials createScoped(
698691
serviceAccountUser,
699692
projectId,
700693
quotaProjectId,
701-
lifetime);
694+
lifetime,
695+
useJwtAccessWithScope);
702696
}
703697

704698
/**
@@ -714,6 +708,16 @@ public ServiceAccountCredentials createWithCustomLifetime(int lifetime) {
714708
return this.toBuilder().setLifetime(lifetime).build();
715709
}
716710

711+
/**
712+
* Clones the service account with a new useJwtAccessWithScope value.
713+
*
714+
* @param useJwtAccessWithScope whether self signed JWT with scopes should be used
715+
* @return the cloned service account credentials with the given useJwtAccessWithScope
716+
*/
717+
public ServiceAccountCredentials createWithUseJwtAccessWithScope(boolean useJwtAccessWithScope) {
718+
return this.toBuilder().setUseJwtAccessWithScope(useJwtAccessWithScope).build();
719+
}
720+
717721
@Override
718722
public GoogleCredentials createDelegated(String user) {
719723
return new ServiceAccountCredentials(
@@ -728,7 +732,8 @@ public GoogleCredentials createDelegated(String user) {
728732
user,
729733
projectId,
730734
quotaProjectId,
731-
lifetime);
735+
lifetime,
736+
useJwtAccessWithScope);
732737
}
733738

734739
public final String getClientId() {
@@ -776,6 +781,10 @@ int getLifetime() {
776781
return lifetime;
777782
}
778783

784+
public boolean getUseJwtAccessWithScope() {
785+
return useJwtAccessWithScope;
786+
}
787+
779788
@Override
780789
public String getAccount() {
781790
return getClientEmail();
@@ -833,7 +842,8 @@ public int hashCode() {
833842
scopes,
834843
defaultScopes,
835844
quotaProjectId,
836-
lifetime);
845+
lifetime,
846+
useJwtAccessWithScope);
837847
}
838848

839849
@Override
@@ -849,6 +859,7 @@ public String toString() {
849859
.add("serviceAccountUser", serviceAccountUser)
850860
.add("quotaProjectId", quotaProjectId)
851861
.add("lifetime", lifetime)
862+
.add("useJwtAccessWithScope", useJwtAccessWithScope)
852863
.toString();
853864
}
854865

@@ -867,7 +878,8 @@ public boolean equals(Object obj) {
867878
&& Objects.equals(this.scopes, other.scopes)
868879
&& Objects.equals(this.defaultScopes, other.defaultScopes)
869880
&& Objects.equals(this.quotaProjectId, other.quotaProjectId)
870-
&& Objects.equals(this.lifetime, other.lifetime);
881+
&& Objects.equals(this.lifetime, other.lifetime)
882+
&& Objects.equals(this.useJwtAccessWithScope, other.useJwtAccessWithScope);
871883
}
872884

873885
String createAssertion(JsonFactory jsonFactory, long currentTime, String audience)
@@ -937,11 +949,58 @@ String createAssertionForIdToken(
937949
}
938950
}
939951

952+
/**
953+
* Self signed JWT uses uri as audience, which should have the "https://{host}/" format. For
954+
* instance, if the uri is "https://compute.googleapis.com/compute/v1/projects/", then this
955+
* function returns "https://compute.googleapis.com/".
956+
*/
957+
@VisibleForTesting
958+
static URI getUriForSelfSignedJWT(URI uri) {
959+
if (uri == null || uri.getScheme() == null || uri.getHost() == null) {
960+
return uri;
961+
}
962+
try {
963+
return new URI(uri.getScheme(), uri.getHost(), "/", null);
964+
} catch (URISyntaxException unused) {
965+
return uri;
966+
}
967+
}
968+
969+
@VisibleForTesting
970+
JwtCredentials createSelfSignedJwtCredentials(final URI uri) {
971+
// Create a JwtCredentials for self signed JWT. See https://google.aip.dev/auth/4111.
972+
JwtClaims.Builder claimsBuilder =
973+
JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail);
974+
975+
if (uri == null) {
976+
// If uri is null, use scopes.
977+
String scopeClaim = "";
978+
if (!scopes.isEmpty()) {
979+
scopeClaim = Joiner.on(' ').join(scopes);
980+
} else {
981+
scopeClaim = Joiner.on(' ').join(defaultScopes);
982+
}
983+
claimsBuilder.setAdditionalClaims(Collections.singletonMap("scope", scopeClaim));
984+
} else {
985+
// otherwise, use audience with the uri.
986+
claimsBuilder.setAudience(getUriForSelfSignedJWT(uri).toString());
987+
}
988+
return JwtCredentials.newBuilder()
989+
.setPrivateKey(privateKey)
990+
.setPrivateKeyId(privateKeyId)
991+
.setJwtClaims(claimsBuilder.build())
992+
.setClock(clock)
993+
.build();
994+
}
995+
940996
@Override
941997
public void getRequestMetadata(
942998
final URI uri, Executor executor, final RequestMetadataCallback callback) {
943-
if (jwtCredentials != null && uri != null) {
944-
jwtCredentials.getRequestMetadata(uri, executor, callback);
999+
if (useJwtAccessWithScope) {
1000+
// This will call getRequestMetadata(URI uri), which handles self signed JWT logic.
1001+
// Self signed JWT doesn't use network, so here we do a blocking call to improve
1002+
// efficiency. executor will be ignored since it is intended for async operation.
1003+
blockingGetToCallback(uri, callback);
9451004
} else {
9461005
super.getRequestMetadata(uri, executor, callback);
9471006
}
@@ -950,17 +1009,31 @@ public void getRequestMetadata(
9501009
/** Provide the request metadata by putting an access JWT directly in the metadata. */
9511010
@Override
9521011
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
953-
if (scopes.isEmpty() && defaultScopes.isEmpty() && uri == null) {
1012+
if (createScopedRequired() && uri == null) {
9541013
throw new IOException(
955-
"Scopes and uri are not configured for service account. Either pass uri"
956-
+ " to getRequestMetadata to use self signed JWT, or specify the scopes"
957-
+ " by calling createScoped or passing scopes to constructor.");
1014+
"Scopes and uri are not configured for service account. Specify the scopes"
1015+
+ " by calling createScoped or passing scopes to constructor or"
1016+
+ " providing uri to getRequestMetadata.");
9581017
}
959-
if (jwtCredentials != null && uri != null) {
960-
return jwtCredentials.getRequestMetadata(uri);
961-
} else {
1018+
1019+
// If scopes are provided but we cannot use self signed JWT, then use scopes to get access
1020+
// token.
1021+
if (!createScopedRequired() && !useJwtAccessWithScope) {
9621022
return super.getRequestMetadata(uri);
9631023
}
1024+
1025+
// If scopes are provided and self signed JWT can be used, use self signed JWT with scopes.
1026+
// Otherwise, use self signed JWT with uri as the audience.
1027+
JwtCredentials jwtCredentials;
1028+
if (!createScopedRequired() && useJwtAccessWithScope) {
1029+
// Create JWT credentials with the scopes.
1030+
jwtCredentials = createSelfSignedJwtCredentials(null);
1031+
} else {
1032+
// Create JWT credentials with the uri as audience.
1033+
jwtCredentials = createSelfSignedJwtCredentials(uri);
1034+
}
1035+
Map<String, List<String>> requestMetadata = jwtCredentials.getRequestMetadata(null);
1036+
return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata);
9641037
}
9651038

9661039
@SuppressWarnings("unused")
@@ -997,6 +1070,7 @@ public static class Builder extends GoogleCredentials.Builder {
9971070
private HttpTransportFactory transportFactory;
9981071
private String quotaProjectId;
9991072
private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;
1073+
private boolean useJwtAccessWithScope = false;
10001074

10011075
protected Builder() {}
10021076

@@ -1013,6 +1087,7 @@ protected Builder(ServiceAccountCredentials credentials) {
10131087
this.projectId = credentials.projectId;
10141088
this.quotaProjectId = credentials.quotaProjectId;
10151089
this.lifetime = credentials.lifetime;
1090+
this.useJwtAccessWithScope = credentials.useJwtAccessWithScope;
10161091
}
10171092

10181093
public Builder setClientId(String clientId) {
@@ -1077,6 +1152,11 @@ public Builder setLifetime(int lifetime) {
10771152
return this;
10781153
}
10791154

1155+
public Builder setUseJwtAccessWithScope(boolean useJwtAccessWithScope) {
1156+
this.useJwtAccessWithScope = useJwtAccessWithScope;
1157+
return this;
1158+
}
1159+
10801160
public String getClientId() {
10811161
return clientId;
10821162
}
@@ -1125,6 +1205,10 @@ public int getLifetime() {
11251205
return lifetime;
11261206
}
11271207

1208+
public boolean getUseJwtAccessWithScope() {
1209+
return useJwtAccessWithScope;
1210+
}
1211+
11281212
public ServiceAccountCredentials build() {
11291213
return new ServiceAccountCredentials(
11301214
clientId,
@@ -1138,7 +1222,8 @@ public ServiceAccountCredentials build() {
11381222
serviceAccountUser,
11391223
projectId,
11401224
quotaProjectId,
1141-
lifetime);
1225+
lifetime,
1226+
useJwtAccessWithScope);
11421227
}
11431228
}
11441229
}

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

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@
5454
import java.io.InputStream;
5555
import java.io.ObjectInputStream;
5656
import java.net.URI;
57-
import java.net.URISyntaxException;
5857
import java.nio.charset.StandardCharsets;
5958
import java.security.InvalidKeyException;
6059
import java.security.NoSuchAlgorithmException;
@@ -332,35 +331,17 @@ public boolean hasRequestMetadataOnly() {
332331
return true;
333332
}
334333

335-
/**
336-
* Self signed JWT uses uri as audience, which should have the "https://{host}/" format. For
337-
* instance, if the uri is "https://compute.googleapis.com/compute/v1/projects/", then this
338-
* function returns "https://compute.googleapis.com/".
339-
*/
340-
@VisibleForTesting
341-
static URI getUriForSelfSignedJWT(URI uri) {
342-
if (uri == null || uri.getScheme() == null || uri.getHost() == null) {
343-
return uri;
344-
}
345-
try {
346-
return new URI(uri.getScheme(), uri.getHost(), "/", null);
347-
} catch (URISyntaxException unused) {
348-
return uri;
349-
}
350-
}
351-
352334
@Override
353335
public void getRequestMetadata(
354336
final URI uri, Executor executor, final RequestMetadataCallback callback) {
355337
// It doesn't use network. Only some CPU work on par with TLS handshake. So it's preferrable
356338
// to do it in the current thread, which is likely to be the network thread.
357-
blockingGetToCallback(getUriForSelfSignedJWT(uri), callback);
339+
blockingGetToCallback(uri, callback);
358340
}
359341

360342
/** Provide the request metadata by putting an access JWT directly in the metadata. */
361343
@Override
362344
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
363-
uri = getUriForSelfSignedJWT(uri);
364345
if (uri == null) {
365346
if (defaultAudience != null) {
366347
uri = defaultAudience;

oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,16 @@ public void testMergeAdditionalClaims() {
136136
assertEquals("bar", mergedAdditionalClaims.get("foo"));
137137
assertEquals("qwer", mergedAdditionalClaims.get("asdf"));
138138
}
139+
140+
@Test
141+
public void testIsComplete() {
142+
// Test JwtClaim is complete if audience is not set but scope is provided.
143+
JwtClaims claims =
144+
JwtClaims.newBuilder()
145+
.setIssuer("issuer-1")
146+
.setSubject("subject-1")
147+
.setAdditionalClaims(Collections.singletonMap("scope", "foo"))
148+
.build();
149+
assertTrue(claims.isComplete());
150+
}
139151
}

0 commit comments

Comments
 (0)