Skip to content

Commit

Permalink
feat: allow scopes for self signed jwt (#689)
Browse files Browse the repository at this point in the history
* feat: self signed jwt support

* update

* address comments

* allow to use uri as audience

* address comments
  • Loading branch information
arithmetic1728 committed Jul 14, 2021
1 parent dfe118c commit f4980c7
Show file tree
Hide file tree
Showing 6 changed files with 230 additions and 94 deletions.
4 changes: 3 additions & 1 deletion oauth2_http/java/com/google/auth/oauth2/JwtClaims.java
Expand Up @@ -106,7 +106,9 @@ public JwtClaims merge(JwtClaims other) {
* @return true if all required fields have been set; false otherwise
*/
public boolean isComplete() {
return getAudience() != null && getIssuer() != null && getSubject() != null;
boolean hasScopes =
getAdditionalClaims().containsKey("scope") && !getAdditionalClaims().get("scope").isEmpty();
return (getAudience() != null || hasScopes) && getIssuer() != null && getSubject() != null;
}

@AutoValue.Builder
Expand Down
143 changes: 114 additions & 29 deletions oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java
Expand Up @@ -77,6 +77,7 @@
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Collection;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -109,9 +110,9 @@ public class ServiceAccountCredentials extends GoogleCredentials
private final Collection<String> defaultScopes;
private final String quotaProjectId;
private final int lifetime;
private final boolean useJwtAccessWithScope;

private transient HttpTransportFactory transportFactory;
private transient ServiceAccountJwtAccessCredentials jwtCredentials = null;

/**
* Constructor with minimum identifying information and custom HTTP transport.
Expand All @@ -133,6 +134,7 @@ public class ServiceAccountCredentials extends GoogleCredentials
* most 43200 (12 hours). If the token is used for calling a Google API, then the value should
* be at most 3600 (1 hour). If the given value is 0, then the default value 3600 will be used
* when creating the credentials.
* @param useJwtAccessWithScope whether self signed JWT with scopes should be always used.
*/
ServiceAccountCredentials(
String clientId,
Expand All @@ -146,7 +148,8 @@ public class ServiceAccountCredentials extends GoogleCredentials
String serviceAccountUser,
String projectId,
String quotaProjectId,
int lifetime) {
int lifetime,
boolean useJwtAccessWithScope) {
this.clientId = clientId;
this.clientEmail = Preconditions.checkNotNull(clientEmail);
this.privateKey = Preconditions.checkNotNull(privateKey);
Expand All @@ -167,18 +170,7 @@ public class ServiceAccountCredentials extends GoogleCredentials
throw new IllegalStateException("lifetime must be less than or equal to 43200");
}
this.lifetime = lifetime;

// Use self signed JWT if scopes is not set, see https://google.aip.dev/auth/4111.
if (this.scopes.isEmpty()) {
jwtCredentials =
new ServiceAccountJwtAccessCredentials.Builder()
.setClientEmail(clientEmail)
.setClientId(clientId)
.setPrivateKey(privateKey)
.setPrivateKeyId(privateKeyId)
.setQuotaProjectId(quotaProjectId)
.build();
}
this.useJwtAccessWithScope = useJwtAccessWithScope;
}

/**
Expand Down Expand Up @@ -492,7 +484,8 @@ static ServiceAccountCredentials fromPkcs8(
serviceAccountUser,
projectId,
quotaProject,
DEFAULT_LIFETIME_IN_SECONDS);
DEFAULT_LIFETIME_IN_SECONDS,
false);
}

/** Helper to convert from a PKCS#8 String to an RSA private key */
Expand Down Expand Up @@ -698,7 +691,8 @@ public GoogleCredentials createScoped(
serviceAccountUser,
projectId,
quotaProjectId,
lifetime);
lifetime,
useJwtAccessWithScope);
}

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

/**
* Clones the service account with a new useJwtAccessWithScope value.
*
* @param useJwtAccessWithScope whether self signed JWT with scopes should be used
* @return the cloned service account credentials with the given useJwtAccessWithScope
*/
public ServiceAccountCredentials createWithUseJwtAccessWithScope(boolean useJwtAccessWithScope) {
return this.toBuilder().setUseJwtAccessWithScope(useJwtAccessWithScope).build();
}

@Override
public GoogleCredentials createDelegated(String user) {
return new ServiceAccountCredentials(
Expand All @@ -728,7 +732,8 @@ public GoogleCredentials createDelegated(String user) {
user,
projectId,
quotaProjectId,
lifetime);
lifetime,
useJwtAccessWithScope);
}

public final String getClientId() {
Expand Down Expand Up @@ -776,6 +781,10 @@ int getLifetime() {
return lifetime;
}

public boolean getUseJwtAccessWithScope() {
return useJwtAccessWithScope;
}

@Override
public String getAccount() {
return getClientEmail();
Expand Down Expand Up @@ -833,7 +842,8 @@ public int hashCode() {
scopes,
defaultScopes,
quotaProjectId,
lifetime);
lifetime,
useJwtAccessWithScope);
}

@Override
Expand All @@ -849,6 +859,7 @@ public String toString() {
.add("serviceAccountUser", serviceAccountUser)
.add("quotaProjectId", quotaProjectId)
.add("lifetime", lifetime)
.add("useJwtAccessWithScope", useJwtAccessWithScope)
.toString();
}

Expand All @@ -867,7 +878,8 @@ public boolean equals(Object obj) {
&& Objects.equals(this.scopes, other.scopes)
&& Objects.equals(this.defaultScopes, other.defaultScopes)
&& Objects.equals(this.quotaProjectId, other.quotaProjectId)
&& Objects.equals(this.lifetime, other.lifetime);
&& Objects.equals(this.lifetime, other.lifetime)
&& Objects.equals(this.useJwtAccessWithScope, other.useJwtAccessWithScope);
}

String createAssertion(JsonFactory jsonFactory, long currentTime, String audience)
Expand Down Expand Up @@ -937,11 +949,58 @@ String createAssertionForIdToken(
}
}

/**
* Self signed JWT uses uri as audience, which should have the "https://{host}/" format. For
* instance, if the uri is "https://compute.googleapis.com/compute/v1/projects/", then this
* function returns "https://compute.googleapis.com/".
*/
@VisibleForTesting
static URI getUriForSelfSignedJWT(URI uri) {
if (uri == null || uri.getScheme() == null || uri.getHost() == null) {
return uri;
}
try {
return new URI(uri.getScheme(), uri.getHost(), "/", null);
} catch (URISyntaxException unused) {
return uri;
}
}

@VisibleForTesting
JwtCredentials createSelfSignedJwtCredentials(final URI uri) {
// Create a JwtCredentials for self signed JWT. See https://google.aip.dev/auth/4111.
JwtClaims.Builder claimsBuilder =
JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail);

if (uri == null) {
// If uri is null, use scopes.
String scopeClaim = "";
if (!scopes.isEmpty()) {
scopeClaim = Joiner.on(' ').join(scopes);
} else {
scopeClaim = Joiner.on(' ').join(defaultScopes);
}
claimsBuilder.setAdditionalClaims(Collections.singletonMap("scope", scopeClaim));
} else {
// otherwise, use audience with the uri.
claimsBuilder.setAudience(getUriForSelfSignedJWT(uri).toString());
}
return JwtCredentials.newBuilder()
.setPrivateKey(privateKey)
.setPrivateKeyId(privateKeyId)
.setJwtClaims(claimsBuilder.build())
.setClock(clock)
.build();
}

@Override
public void getRequestMetadata(
final URI uri, Executor executor, final RequestMetadataCallback callback) {
if (jwtCredentials != null && uri != null) {
jwtCredentials.getRequestMetadata(uri, executor, callback);
if (useJwtAccessWithScope) {
// This will call getRequestMetadata(URI uri), which handles self signed JWT logic.
// Self signed JWT doesn't use network, so here we do a blocking call to improve
// efficiency. executor will be ignored since it is intended for async operation.
blockingGetToCallback(uri, callback);
} else {
super.getRequestMetadata(uri, executor, callback);
}
Expand All @@ -950,17 +1009,31 @@ public void getRequestMetadata(
/** Provide the request metadata by putting an access JWT directly in the metadata. */
@Override
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
if (scopes.isEmpty() && defaultScopes.isEmpty() && uri == null) {
if (createScopedRequired() && uri == null) {
throw new IOException(
"Scopes and uri are not configured for service account. Either pass uri"
+ " to getRequestMetadata to use self signed JWT, or specify the scopes"
+ " by calling createScoped or passing scopes to constructor.");
"Scopes and uri are not configured for service account. Specify the scopes"
+ " by calling createScoped or passing scopes to constructor or"
+ " providing uri to getRequestMetadata.");
}
if (jwtCredentials != null && uri != null) {
return jwtCredentials.getRequestMetadata(uri);
} else {

// If scopes are provided but we cannot use self signed JWT, then use scopes to get access
// token.
if (!createScopedRequired() && !useJwtAccessWithScope) {
return super.getRequestMetadata(uri);
}

// If scopes are provided and self signed JWT can be used, use self signed JWT with scopes.
// Otherwise, use self signed JWT with uri as the audience.
JwtCredentials jwtCredentials;
if (!createScopedRequired() && useJwtAccessWithScope) {
// Create JWT credentials with the scopes.
jwtCredentials = createSelfSignedJwtCredentials(null);
} else {
// Create JWT credentials with the uri as audience.
jwtCredentials = createSelfSignedJwtCredentials(uri);
}
Map<String, List<String>> requestMetadata = jwtCredentials.getRequestMetadata(null);
return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata);
}

@SuppressWarnings("unused")
Expand Down Expand Up @@ -997,6 +1070,7 @@ public static class Builder extends GoogleCredentials.Builder {
private HttpTransportFactory transportFactory;
private String quotaProjectId;
private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;
private boolean useJwtAccessWithScope = false;

protected Builder() {}

Expand All @@ -1013,6 +1087,7 @@ protected Builder(ServiceAccountCredentials credentials) {
this.projectId = credentials.projectId;
this.quotaProjectId = credentials.quotaProjectId;
this.lifetime = credentials.lifetime;
this.useJwtAccessWithScope = credentials.useJwtAccessWithScope;
}

public Builder setClientId(String clientId) {
Expand Down Expand Up @@ -1077,6 +1152,11 @@ public Builder setLifetime(int lifetime) {
return this;
}

public Builder setUseJwtAccessWithScope(boolean useJwtAccessWithScope) {
this.useJwtAccessWithScope = useJwtAccessWithScope;
return this;
}

public String getClientId() {
return clientId;
}
Expand Down Expand Up @@ -1125,6 +1205,10 @@ public int getLifetime() {
return lifetime;
}

public boolean getUseJwtAccessWithScope() {
return useJwtAccessWithScope;
}

public ServiceAccountCredentials build() {
return new ServiceAccountCredentials(
clientId,
Expand All @@ -1138,7 +1222,8 @@ public ServiceAccountCredentials build() {
serviceAccountUser,
projectId,
quotaProjectId,
lifetime);
lifetime,
useJwtAccessWithScope);
}
}
}
Expand Up @@ -54,7 +54,6 @@
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
Expand Down Expand Up @@ -332,35 +331,17 @@ public boolean hasRequestMetadataOnly() {
return true;
}

/**
* Self signed JWT uses uri as audience, which should have the "https://{host}/" format. For
* instance, if the uri is "https://compute.googleapis.com/compute/v1/projects/", then this
* function returns "https://compute.googleapis.com/".
*/
@VisibleForTesting
static URI getUriForSelfSignedJWT(URI uri) {
if (uri == null || uri.getScheme() == null || uri.getHost() == null) {
return uri;
}
try {
return new URI(uri.getScheme(), uri.getHost(), "/", null);
} catch (URISyntaxException unused) {
return uri;
}
}

@Override
public void getRequestMetadata(
final URI uri, Executor executor, final RequestMetadataCallback callback) {
// It doesn't use network. Only some CPU work on par with TLS handshake. So it's preferrable
// to do it in the current thread, which is likely to be the network thread.
blockingGetToCallback(getUriForSelfSignedJWT(uri), callback);
blockingGetToCallback(uri, callback);
}

/** Provide the request metadata by putting an access JWT directly in the metadata. */
@Override
public Map<String, List<String>> getRequestMetadata(URI uri) throws IOException {
uri = getUriForSelfSignedJWT(uri);
if (uri == null) {
if (defaultAudience != null) {
uri = defaultAudience;
Expand Down
12 changes: 12 additions & 0 deletions oauth2_http/javatests/com/google/auth/oauth2/JwtClaimsTest.java
Expand Up @@ -136,4 +136,16 @@ public void testMergeAdditionalClaims() {
assertEquals("bar", mergedAdditionalClaims.get("foo"));
assertEquals("qwer", mergedAdditionalClaims.get("asdf"));
}

@Test
public void testIsComplete() {
// Test JwtClaim is complete if audience is not set but scope is provided.
JwtClaims claims =
JwtClaims.newBuilder()
.setIssuer("issuer-1")
.setSubject("subject-1")
.setAdditionalClaims(Collections.singletonMap("scope", "foo"))
.build();
assertTrue(claims.isComplete());
}
}

0 comments on commit f4980c7

Please sign in to comment.