Skip to content

Commit

Permalink
feat: self signed jwt support
Browse files Browse the repository at this point in the history
  • Loading branch information
arithmetic1728 committed Jun 28, 2021
1 parent 5a8f467 commit a974cbe
Show file tree
Hide file tree
Showing 6 changed files with 148 additions and 95 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
156 changes: 121 additions & 35 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,10 @@ public class ServiceAccountCredentials extends GoogleCredentials
private final Collection<String> defaultScopes;
private final String quotaProjectId;
private final int lifetime;
private final boolean alwaysUseJwtAccess;
private final String defaultHost;

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

/**
* Constructor with minimum identifying information and custom HTTP transport.
Expand All @@ -133,6 +135,8 @@ 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 alwaysUseJwtAccess whether self signed JWT should be always used.
* @param defaultHost the default host which will be used as audience for self signed JWT.
*/
ServiceAccountCredentials(
String clientId,
Expand All @@ -146,7 +150,9 @@ public class ServiceAccountCredentials extends GoogleCredentials
String serviceAccountUser,
String projectId,
String quotaProjectId,
int lifetime) {
int lifetime,
boolean alwaysUseJwtAccess,
String defaultHost) {
this.clientId = clientId;
this.clientEmail = Preconditions.checkNotNull(clientEmail);
this.privateKey = Preconditions.checkNotNull(privateKey);
Expand All @@ -167,18 +173,8 @@ 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.alwaysUseJwtAccess = alwaysUseJwtAccess;
this.defaultHost = defaultHost;
}

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

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

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

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

/**
* Clones the service account with a new defaultHost value.
*
* @param defaultHost the default host which will be used as audience for self signed JWT.
* @return the cloned service account credentials with the given defaultHost
*/
public ServiceAccountCredentials createWithDefaultHost(String defaultHost) {
return this.toBuilder().setDefaultHost(defaultHost).build();
}

@Override
public GoogleCredentials createDelegated(String user) {
return new ServiceAccountCredentials(
Expand All @@ -728,7 +748,9 @@ public GoogleCredentials createDelegated(String user) {
user,
projectId,
quotaProjectId,
lifetime);
lifetime,
alwaysUseJwtAccess,
defaultHost);
}

public final String getClientId() {
Expand All @@ -748,7 +770,7 @@ public final String getPrivateKeyId() {
}

public final Collection<String> getScopes() {
return scopes;
return scopes.isEmpty() ? defaultScopes : scopes;
}

public final Collection<String> getDefaultScopes() {
Expand Down Expand Up @@ -776,6 +798,16 @@ int getLifetime() {
return lifetime;
}

@VisibleForTesting
boolean getAlwaysUseJwtAccess() {
return alwaysUseJwtAccess;
}

@VisibleForTesting
String getDefaultHost() {
return defaultHost;
}

@Override
public String getAccount() {
return getClientEmail();
Expand Down Expand Up @@ -833,7 +865,9 @@ public int hashCode() {
scopes,
defaultScopes,
quotaProjectId,
lifetime);
lifetime,
alwaysUseJwtAccess,
defaultHost);
}

@Override
Expand All @@ -849,6 +883,8 @@ public String toString() {
.add("serviceAccountUser", serviceAccountUser)
.add("quotaProjectId", quotaProjectId)
.add("lifetime", lifetime)
.add("alwaysUseJwtAccess", alwaysUseJwtAccess)
.add("defaultHost", defaultHost)
.toString();
}

Expand All @@ -867,7 +903,9 @@ 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.alwaysUseJwtAccess, other.alwaysUseJwtAccess)
&& Objects.equals(this.defaultHost, other.defaultHost);
}

String createAssertion(JsonFactory jsonFactory, long currentTime, String audience)
Expand All @@ -882,11 +920,7 @@ String createAssertion(JsonFactory jsonFactory, long currentTime, String audienc
payload.setIssuedAtTimeSeconds(currentTime / 1000);
payload.setExpirationTimeSeconds(currentTime / 1000 + this.lifetime);
payload.setSubject(serviceAccountUser);
if (scopes.isEmpty()) {
payload.put("scope", Joiner.on(' ').join(defaultScopes));
} else {
payload.put("scope", Joiner.on(' ').join(scopes));
}
payload.put("scope", Joiner.on(' ').join(getScopes()));

if (audience == null) {
payload.setAudience(OAuth2Utils.TOKEN_SERVER_URI.toString());
Expand Down Expand Up @@ -937,11 +971,34 @@ String createAssertionForIdToken(
}
}

@VisibleForTesting
JwtCredentials createSelfSignedJwtCredentials() {
// Create a JwtCredentials for self signed JWT. See https://google.aip.dev/auth/4111.
JwtClaims.Builder claimsBuilder =
JwtClaims.newBuilder().setIssuer(clientEmail).setSubject(clientEmail);
if (!scopes.isEmpty()) {
claimsBuilder.setAdditionalClaims(
Collections.singletonMap("scope", Joiner.on(' ').join(scopes)));
} else if (defaultHost != null) {
claimsBuilder.setAudience(defaultHost);
} else {
claimsBuilder.setAdditionalClaims(
Collections.singletonMap("scope", Joiner.on(' ').join(defaultScopes)));
}
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 (alwaysUseJwtAccess) {
// This will call getRequestMetadata(URI uri), which handles self signed JWT logic.
blockingGetToCallback(uri, callback);
} else {
super.getRequestMetadata(uri, executor, callback);
}
Expand All @@ -950,14 +1007,19 @@ 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) {
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.");
if (createScopedRequired()) {
if (!alwaysUseJwtAccess) {
throw new IOException(
"Scopes are not configured for service account. Specify the scopes"
+ " by calling createScoped or passing scopes to constructor.");
} else if (defaultHost == null) {
throw new IOException("Scopes and defaultHost are not configured for service account.");
}
}
if (jwtCredentials != null && uri != null) {
return jwtCredentials.getRequestMetadata(uri);
if (alwaysUseJwtAccess) {
JwtCredentials jwtCredentials = createSelfSignedJwtCredentials();
Map<String, List<String>> requestMetadata = jwtCredentials.getRequestMetadata(null);
return addQuotaProjectIdToRequestMetadata(quotaProjectId, requestMetadata);
} else {
return super.getRequestMetadata(uri);
}
Expand Down Expand Up @@ -997,6 +1059,8 @@ public static class Builder extends GoogleCredentials.Builder {
private HttpTransportFactory transportFactory;
private String quotaProjectId;
private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;
private boolean alwaysUseJwtAccess = false;
private String defaultHost;

protected Builder() {}

Expand All @@ -1013,6 +1077,8 @@ protected Builder(ServiceAccountCredentials credentials) {
this.projectId = credentials.projectId;
this.quotaProjectId = credentials.quotaProjectId;
this.lifetime = credentials.lifetime;
this.alwaysUseJwtAccess = credentials.alwaysUseJwtAccess;
this.defaultHost = credentials.defaultHost;
}

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

public Builder setAlwaysUseJwtAccess(boolean alwaysUseJwtAccess) {
this.alwaysUseJwtAccess = alwaysUseJwtAccess;
return this;
}

public Builder setDefaultHost(String defaultHost) {
this.defaultHost = defaultHost;
return this;
}

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

public boolean getAlwaysUseJwtAccess() {
return alwaysUseJwtAccess;
}

public String getDefaultHost() {
return defaultHost;
}

public ServiceAccountCredentials build() {
return new ServiceAccountCredentials(
clientId,
Expand All @@ -1138,7 +1222,9 @@ public ServiceAccountCredentials build() {
serviceAccountUser,
projectId,
quotaProjectId,
lifetime);
lifetime,
alwaysUseJwtAccess,
defaultHost);
}
}
}
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 a974cbe

Please sign in to comment.