Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
dc5b79c
fix: allow for ES algorithm in GdchCredentials
diegomarquezp Mar 4, 2026
0c5e423
test: partially adapt tests
diegomarquezp Mar 4, 2026
5851b3e
test: finish adjusting tests
diegomarquezp Mar 4, 2026
258ac9d
chore: format
diegomarquezp Mar 4, 2026
e8511bb
fix: restore credential name
diegomarquezp Mar 4, 2026
a4d0f93
docs: restore license
diegomarquezp Mar 4, 2026
e3d5302
fix: restore removed code
diegomarquezp Mar 4, 2026
e6635c6
test: increase coverage
diegomarquezp Mar 5, 2026
cc49dba
test(gdch): parameterize test
diegomarquezp Mar 10, 2026
f58f593
test: remove unused var
diegomarquezp Mar 10, 2026
42f2662
chore: remove unused throw clause
diegomarquezp Mar 10, 2026
91a3751
test: parameterize more
diegomarquezp Mar 10, 2026
b83a511
fix: remove unused parameter
diegomarquezp Mar 10, 2026
625741e
fix: make variables final as intended
diegomarquezp Mar 10, 2026
1e0a7c3
fix: remove unused throw clause
diegomarquezp Mar 10, 2026
24cd25d
fix: remove unused throw clause
diegomarquezp Mar 10, 2026
027a242
fix: make OAuth2Credentials clock package private for production code
diegomarquezp Mar 10, 2026
a8b459b
fix: use non deprecated base 64 encoder
diegomarquezp Mar 10, 2026
4e8af24
test: parameterize flagged tests
diegomarquezp Mar 10, 2026
f257a81
chore: format
diegomarquezp Mar 10, 2026
c523df2
test: fix assertion
diegomarquezp Mar 10, 2026
ed026a4
build: remove unused dependency
diegomarquezp Mar 10, 2026
1528e76
test: run linux gce only on linux envs
diegomarquezp Mar 10, 2026
7300e59
fix: sonarqube flags (use java.util.Base64)
diegomarquezp Mar 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 158 additions & 33 deletions oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,18 @@
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.UrlEncodedContent;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.http.json.JsonHttpContent;
import com.google.api.client.json.GenericJson;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.json.webtoken.JsonWebSignature;
import com.google.api.client.json.webtoken.JsonWebToken;
import com.google.api.client.util.Clock;
import com.google.api.client.util.GenericData;
import com.google.api.client.util.SecurityUtils;
import com.google.api.client.util.StringUtils;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.MoreObjects;
Expand All @@ -56,25 +60,36 @@
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.math.BigDecimal;
import java.net.URI;
import java.net.URISyntaxException;
import java.security.GeneralSecurityException;
import java.security.PrivateKey;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import java.util.Objects;

public class GdchCredentials extends GoogleCredentials {
static final String SUPPORTED_FORMAT_VERSION = "1";
private static final String VALUE_NOT_FOUND_MESSAGE = "%sExpected value %s not found.";
private static final String VALUE_WRONG_TYPE_MESSAGE = "%sExpected %s value %s of wrong type.";
private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. ";
@VisibleForTesting static final String SUPPORTED_FORMAT_VERSION = "1";

private static final String ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
private static final String SERVICE_ACCOUNT_TOKEN_TYPE =
"urn:k8s:params:oauth:token-type:serviceaccount";
private static final String TOKEN_TYPE_TOKEN_EXCHANGE =
"urn:ietf:params:oauth:token-type:token-exchange";

private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600;

private final PrivateKey privateKey;
private final String privateKeyId;
private final String projectId;
private final String serviceIdentityName;
private final URI tokenServerUri;
private final URI apiAudience;
private final String apiAudience;
private final int lifetime;
private final String transportFactoryClassName;
private final String caCertPath;
Expand Down Expand Up @@ -218,7 +233,7 @@ static GdchCredentials fromJson(Map<String, Object> json, HttpTransportFactory t
*/
static GdchCredentials fromPkcs8(String privateKeyPkcs8, GdchCredentials.Builder builder)
throws IOException {
PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(privateKeyPkcs8);
PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(privateKeyPkcs8, "EC");
builder.setPrivateKey(privateKey);

return new GdchCredentials(builder);
Expand All @@ -229,7 +244,7 @@ static GdchCredentials fromPkcs8(String privateKeyPkcs8, GdchCredentials.Builder
*
* @param apiAudience The intended audience for GDCH credentials.
*/
public GdchCredentials createWithGdchAudience(URI apiAudience) throws IOException {
public GdchCredentials createWithGdchAudience(String apiAudience) {
Preconditions.checkNotNull(
apiAudience, "Audience are not configured for GDCH service account credentials.");
return this.toBuilder().setGdchAudience(apiAudience).build();
Expand All @@ -249,14 +264,19 @@ public AccessToken refreshAccessToken() throws IOException {
"Audience are not configured for GDCH service account. Specify the "
+ "audience by calling createWithGDCHAudience.");

JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY;
long currentTime = clock.currentTimeMillis();
String assertion = createAssertion(jsonFactory, currentTime, getApiAudience());
JsonFactory jsonFactory = GsonFactory.getDefaultInstance();

long currentTime = Clock.SYSTEM.currentTimeMillis();
String assertion = createAssertion(jsonFactory, currentTime);

GenericData tokenRequest = new GenericData();
tokenRequest.set("grant_type", OAuth2Utils.TOKEN_TYPE_TOKEN_EXCHANGE);
tokenRequest.set("assertion", assertion);
UrlEncodedContent content = new UrlEncodedContent(tokenRequest);
tokenRequest.set("audience", apiAudience);
tokenRequest.set("grant_type", TOKEN_TYPE_TOKEN_EXCHANGE);
tokenRequest.set("requested_token_type", ACCESS_TOKEN_TYPE);
tokenRequest.set("subject_token", assertion);
tokenRequest.set("subject_token_type", SERVICE_ACCOUNT_TOKEN_TYPE);

JsonHttpContent content = new JsonHttpContent(jsonFactory, tokenRequest);

HttpRequestFactory requestFactory = transportFactory.create().createRequestFactory();
HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(tokenServerUri), content);
Expand All @@ -270,17 +290,15 @@ public AccessToken refreshAccessToken() throws IOException {
response = request.execute();
} catch (HttpResponseException re) {
String message = String.format(errorTemplate, re.getMessage(), getServiceIdentityName());
throw GoogleAuthException.createWithTokenEndpointResponseException(re, message);
throw new IOException(message, re);
} catch (IOException e) {
throw GoogleAuthException.createWithTokenEndpointIOException(
e, String.format(errorTemplate, e.getMessage(), getServiceIdentityName()));
String message = String.format(errorTemplate, e.getMessage(), getServiceIdentityName());
throw new IOException(message, e);
}

GenericData responseData = response.parseAs(GenericData.class);
String accessToken =
OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
int expiresInSeconds =
OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
String accessToken = validateString(responseData, "access_token", PARSE_ERROR_PREFIX);
int expiresInSeconds = validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX);
long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000L;
return new AccessToken(accessToken, new Date(expiresAtMilliseconds));
}
Expand All @@ -292,10 +310,9 @@ public AccessToken refreshAccessToken() throws IOException {
* (tokenServerUri), not for API call. It uses the serviceIdentityName as the `iss` and `sub`
* claim, and the tokenServerUri as the `aud` claim. The JWT is signed with the privateKey.
*/
String createAssertion(JsonFactory jsonFactory, long currentTime, URI apiAudience)
throws IOException {
String createAssertion(JsonFactory jsonFactory, long currentTime) throws IOException {
JsonWebSignature.Header header = new JsonWebSignature.Header();
header.setAlgorithm("RS256");
header.setAlgorithm("ES256");
header.setType("JWT");
header.setKeyId(privateKeyId);

Expand All @@ -304,12 +321,11 @@ String createAssertion(JsonFactory jsonFactory, long currentTime, URI apiAudienc
payload.setSubject(getIssuerSubjectValue(projectId, serviceIdentityName));
payload.setIssuedAtTimeSeconds(currentTime / 1000);
payload.setExpirationTimeSeconds(currentTime / 1000 + this.lifetime);
payload.setAudience(getTokenServerUri().toString());
payload.setAudience(tokenServerUri.toString());

String assertion;
try {
payload.set("api_audience", apiAudience.toString());
assertion = JsonWebSignature.signUsingRsaSha256(privateKey, jsonFactory, header, payload);
assertion = signUsingEsSha256(privateKey, jsonFactory, header, payload);
} catch (GeneralSecurityException e) {
throw new IOException(
"Error signing service account access token request with private key.", e);
Expand All @@ -329,9 +345,6 @@ static String getIssuerSubjectValue(String projectId, String serviceIdentityName
return String.format("system:serviceaccount:%s:%s", projectId, serviceIdentityName);
}

/**
* @return the projectId set in the GDCH SA Key file or the user set projectId
*/
@Override
public final String getProjectId() {
return projectId;
Expand All @@ -353,7 +366,7 @@ public final URI getTokenServerUri() {
return tokenServerUri;
}

public final URI getApiAudience() {
public final String getApiAudience() {
return apiAudience;
}

Expand Down Expand Up @@ -436,7 +449,7 @@ public static class Builder extends GoogleCredentials.Builder {
private PrivateKey privateKey;
private String serviceIdentityName;
private URI tokenServerUri;
private URI apiAudience;
private String apiAudience;
private HttpTransportFactory transportFactory;
private String caCertPath;
private int lifetime = DEFAULT_LIFETIME_IN_SECONDS;
Expand Down Expand Up @@ -497,7 +510,7 @@ public Builder setCaCertPath(String caCertPath) {
}

@CanIgnoreReturnValue
public Builder setGdchAudience(URI apiAudience) {
public Builder setGdchAudience(String apiAudience) {
this.apiAudience = apiAudience;
return this;
}
Expand Down Expand Up @@ -553,13 +566,16 @@ private static String validateField(String field, String fieldName) throws IOExc
/*
* Internal HttpTransportFactory for GDCH credentials.
*
* <p> GDCH authentication server could use a self-signed certificate, thus the client could
* <p> GDCH authentication server could use a self-signed certificate, thus the
* client could
* provide the CA certificate path through the `ca_cert_path` in GDCH JSON file.
*
* <p> The TransportFactoryForGdch subclass would read the certificate and create a trust store,
* <p> The TransportFactoryForGdch subclass would read the certificate and
* create a trust store,
* then use the trust store to create a transport.
*
* <p> If the GDCH authentication server uses well known CA certificate, then a regular transport
* <p> If the GDCH authentication server uses well known CA certificate, then a
* regular transport
* would be set.
*/
static class TransportFactoryForGdch implements HttpTransportFactory {
Expand Down Expand Up @@ -594,4 +610,113 @@ private void setTransport(String caCertPath) throws IOException {
}
}
}

/** Return the specified string from JSON or throw a helpful error message. */
private static String validateString(Map<String, Object> map, String key, String errorPrefix)
throws IOException {
Object value = map.get(key);
if (value == null) {
throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
}
if (!(value instanceof String)) {
throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "string", key));
}
return (String) value;
}

private static int validateInt32(Map<String, Object> map, String key, String errorPrefix)
throws IOException {
Object value = map.get(key);
if (value == null) {
throw new IOException(String.format(VALUE_NOT_FOUND_MESSAGE, errorPrefix, key));
}
if (value instanceof BigDecimal) {
BigDecimal bigDecimalValue = (BigDecimal) value;
return bigDecimalValue.intValueExact();
}
if (!(value instanceof Integer)) {
throw new IOException(String.format(VALUE_WRONG_TYPE_MESSAGE, errorPrefix, "integer", key));
}
return (Integer) value;
}

private static String signUsingEsSha256(
PrivateKey privateKey,
JsonFactory jsonFactory,
JsonWebSignature.Header header,
JsonWebToken.Payload payload)
throws GeneralSecurityException, IOException {
String content =
Base64.getUrlEncoder().withoutPadding().encodeToString(jsonFactory.toByteArray(header))
+ "."
+ Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(jsonFactory.toByteArray(payload));
byte[] contentBytes = StringUtils.getBytesUtf8(content);
byte[] signature =
SecurityUtils.sign(SecurityUtils.getEs256SignatureAlgorithm(), privateKey, contentBytes);

// The JCA returns a DER-encoded signature, but JWS needs the concatenated R|S
// format.
// We need to transcode it. For ES256, the output length is 64 bytes.
byte[] jwsSignature = transcodeDerToConcat(signature, 64);
return content + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jwsSignature);
}

private static byte[] transcodeDerToConcat(byte[] derSignature, int outputLength)
throws IOException {
if (derSignature.length < 8 || derSignature[0] != 0x30) {
throw new IOException("Invalid DER signature format.");
}

int offset = 2;
int seqLength = derSignature[1] & 0xFF;
if (seqLength == 0x81) {
offset = 3;
seqLength = derSignature[2] & 0xFF;
}

if (derSignature.length - offset != seqLength) {
throw new IOException("Invalid DER signature length.");
}

// R
if (derSignature[offset++] != 0x02) {
throw new IOException("Expected INTEGER for R.");
}
int rLength = derSignature[offset++];
if (derSignature[offset] == 0x00 && rLength > 1 && (derSignature[offset + 1] & 0x80) != 0) {
offset++;
rLength--;
}
byte[] r = new byte[rLength];
System.arraycopy(derSignature, offset, r, 0, rLength);
offset += rLength;

// S
if (derSignature[offset++] != 0x02) {
throw new IOException("Expected INTEGER for S.");
}
int sLength = derSignature[offset++];
if (derSignature[offset] == 0x00 && sLength > 1 && (derSignature[offset + 1] & 0x80) != 0) {
offset++;
sLength--;
}
byte[] s = new byte[sLength];
System.arraycopy(derSignature, offset, s, 0, sLength);

int keySizeBytes = outputLength / 2;
if (r.length > keySizeBytes || s.length > keySizeBytes) {
throw new IOException(
String.format(
"Invalid R or S length. R: %d, S: %d, Expected: %d",
r.length, s.length, keySizeBytes));
}

byte[] result = new byte[outputLength];
System.arraycopy(r, 0, result, keySizeBytes - r.length, r.length);
System.arraycopy(s, 0, result, outputLength - s.length, s.length);

return result;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public class OAuth2Credentials extends Credentials {
// Change listeners are not serialized
private transient List<CredentialsChangedListener> changeListeners;
// Until we expose this to the users it can remain transient and non-serializable
@VisibleForTesting transient Clock clock = Clock.SYSTEM;
transient Clock clock = Clock.SYSTEM;

/**
* Returns the credentials instance from the given access token.
Expand Down
17 changes: 15 additions & 2 deletions oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
import com.google.api.client.json.gson.GsonFactory;
import com.google.api.client.util.PemReader;
import com.google.api.client.util.PemReader.Section;
import com.google.api.client.util.SecurityUtils;
import com.google.auth.http.AuthHttpConstants;
import com.google.auth.http.HttpTransportFactory;
import com.google.common.base.Strings;
Expand Down Expand Up @@ -267,6 +266,20 @@ static Map<String, Object> validateMap(Map<String, Object> map, String key, Stri
* key creation.
*/
public static PrivateKey privateKeyFromPkcs8(String privateKeyPkcs8) throws IOException {
return privateKeyFromPkcs8(privateKeyPkcs8, "RSA");
}

/**
* Converts a PKCS#8 string to a private key of the specified algorithm.
*
* @param privateKeyPkcs8 the PKCS#8 string.
* @param algorithm the algorithm of the private key.
* @return the private key.
* @throws IOException if the PKCS#8 data is invalid or if an unexpected exception occurs during
* key creation.
*/
public static PrivateKey privateKeyFromPkcs8(String privateKeyPkcs8, String algorithm)
throws IOException {
Reader reader = new StringReader(privateKeyPkcs8);
Section section = PemReader.readFirstSectionAndClose(reader, "PRIVATE KEY");
if (section == null) {
Expand All @@ -276,7 +289,7 @@ public static PrivateKey privateKeyFromPkcs8(String privateKeyPkcs8) throws IOEx
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(bytes);
Exception unexpectedException;
try {
KeyFactory keyFactory = SecurityUtils.getRsaKeyFactory();
KeyFactory keyFactory = KeyFactory.getInstance(algorithm);
return keyFactory.generatePrivate(keySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
unexpectedException = exception;
Expand Down
20 changes: 20 additions & 0 deletions oauth2_http/javatests/com/google/auth/TestUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,26 @@ public static Map<String, String> parseQuery(String query) throws IOException {
return map;
}

/**
* Parses the request body as either JSON or a query string.
*
* @param content The request body content.
* @return A map of the parsed parameters.
* @throws IOException If the content cannot be parsed.
*/
public static Map<String, String> parseBody(String content) throws IOException {
if (content != null && content.trim().startsWith("{")) {
GenericJson json = JSON_FACTORY.fromString(content, GenericJson.class);
Map<String, String> map = new HashMap<>();
for (Map.Entry<String, Object> entry : json.entrySet()) {
Object value = entry.getValue();
map.put(entry.getKey(), value == null ? null : value.toString());
}
return map;
}
return parseQuery(content);
}

public static String errorJson(String message) throws IOException {
GenericJson errorResponse = new GenericJson();
errorResponse.setFactory(JSON_FACTORY);
Expand Down
Loading
Loading