diff --git a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java index c5e8bd576..77ab9bfc5 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java @@ -37,18 +37,24 @@ 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.api.core.ObsoleteApi; import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; +import com.google.common.base.Strings; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.File; import java.io.FileInputStream; @@ -56,17 +62,27 @@ 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 PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; + private static final String BAD_VALUE_ERROR_MESSAGE_FORMAT = "%s Expected %s %s %s."; + 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; @@ -74,7 +90,7 @@ public class GdchCredentials extends GoogleCredentials { 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; @@ -212,26 +228,42 @@ static GdchCredentials fromJson(Map json, HttpTransportFactory t /** * Internal constructor. * - * @param privateKeyPkcs8 RSA private key object for the service account in PKCS#8 format. + * @param privateKeyPkcs8 EC private key object for the service account in PKCS#8 format. * @param builder A builder for GdchCredentials. * @return an instance of GdchCredentials. */ static GdchCredentials fromPkcs8(String privateKeyPkcs8, GdchCredentials.Builder builder) throws IOException { - PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(privateKeyPkcs8); + PrivateKey privateKey = + OAuth2Utils.privateKeyFromPkcs8(privateKeyPkcs8, OAuth2Utils.Pkcs8Algorithm.EC); builder.setPrivateKey(privateKey); return new GdchCredentials(builder); } /** - * Create a copy of GDCH credentials with the specified audience. + * This method is obsolete. Please use {@link #createWithGdchAudience(String)}} instead. Create a + * copy of GDCH credentials with the specified audience. * * @param apiAudience The intended audience for GDCH credentials. */ - public GdchCredentials createWithGdchAudience(URI apiAudience) throws IOException { + @ObsoleteApi("Use createWithGdchAudience(String) instead.") + public GdchCredentials createWithGdchAudience(URI apiAudience) { Preconditions.checkNotNull( apiAudience, "Audience are not configured for GDCH service account credentials."); + return this.toBuilder().setGdchAudience(apiAudience.toString()).build(); + } + + /** + * Create a copy of GDCH credentials with the specified audience. + * + * @param apiAudience The intended audience for GDCH credentials. + */ + public GdchCredentials createWithGdchAudience(String apiAudience) { + if (Strings.isNullOrEmpty(apiAudience)) { + throw new IllegalArgumentException( + "Audience are not configured for GDCH service account credentials."); + } return this.toBuilder().setGdchAudience(apiAudience).build(); } @@ -249,14 +281,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); @@ -270,17 +307,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)); } @@ -292,10 +327,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); @@ -304,12 +338,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); @@ -329,9 +362,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; @@ -353,10 +383,34 @@ public final URI getTokenServerUri() { return tokenServerUri; } - public final URI getApiAudience() { + /** + * Returns the underlying audience string set for this credentials object. + * + * @return the audience string + */ + public final String getApiAudienceString() { return apiAudience; } + /** + * NOTE: This method is obsolete, please use getApiAudienceString() instead. Returns a URI + * representation of the underlying audience string set for this credentials object. This method + * may fail if the underlying audience string does not conform to a URI format. + * + * @return a URI object representing the audience of the credentials + */ + @ObsoleteApi("Use getApiAudienceString() instead.") + public final URI getApiAudience() { + if (Strings.isNullOrEmpty(apiAudience)) { + return null; + } + try { + return new URI(apiAudience); + } catch (URISyntaxException e) { + return null; + } + } + public final HttpTransportFactory getTransportFactory() { return transportFactory; } @@ -436,7 +490,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; @@ -497,11 +551,18 @@ public Builder setCaCertPath(String caCertPath) { } @CanIgnoreReturnValue - public Builder setGdchAudience(URI apiAudience) { + public Builder setGdchAudience(String apiAudience) { this.apiAudience = apiAudience; return this; } + @CanIgnoreReturnValue + @ObsoleteApi("Use setGdchAudience(String) instead") + public Builder setGdchAudience(URI apiAudience) { + this.apiAudience = apiAudience.toString(); + return this; + } + public String getProjectId() { return projectId; } @@ -553,13 +614,16 @@ private static String validateField(String field, String fieldName) throws IOExc /* * Internal HttpTransportFactory for GDCH credentials. * - *

GDCH authentication server could use a self-signed certificate, thus the client could + *

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. * - *

The TransportFactoryForGdch subclass would read the certificate and create a trust store, + *

The TransportFactoryForGdch subclass would read the certificate and + * create a trust store, * then use the trust store to create a transport. * - *

If the GDCH authentication server uses well known CA certificate, then a regular transport + *

If the GDCH authentication server uses well known CA certificate, then a + * regular transport * would be set. */ static class TransportFactoryForGdch implements HttpTransportFactory { @@ -594,4 +658,162 @@ private void setTransport(String caCertPath) throws IOException { } } } + + /** Return the specified string from JSON or throw a helpful error message. */ + private static String validateString(Map map, String key, String errorPrefix) + throws IOException { + Object value = map.get(key); + if (value == null) { + throw new IOException( + String.format(BAD_VALUE_ERROR_MESSAGE_FORMAT, errorPrefix, "value", key, "not found")); + } + if (!(value instanceof String)) { + throw new IOException( + String.format( + BAD_VALUE_ERROR_MESSAGE_FORMAT, errorPrefix, "string value", key, "of wrong type")); + } + return (String) value; + } + + private static int validateInt32(Map map, String key, String errorPrefix) + throws IOException { + Object value = map.get(key); + if (value == null) { + throw new IOException( + String.format(BAD_VALUE_ERROR_MESSAGE_FORMAT, errorPrefix, "value", key, "not found")); + } + if (value instanceof BigDecimal) { + BigDecimal bigDecimalValue = (BigDecimal) value; + return bigDecimalValue.intValueExact(); + } + if (!(value instanceof Integer)) { + throw new IOException( + String.format( + BAD_VALUE_ERROR_MESSAGE_FORMAT, errorPrefix, "integer value", key, "of wrong type")); + } + return (Integer) value; + } + + /** + * Signs the JWS header and payload using the ES256 algorithm (ECDSA with SHA-256). + * + *

The ES256 algorithm is defined in RFC 7518 Section 3.4. This method + * follows the JWS Compact Serialization format described in RFC 7515 Section 3.1. + * + *

Unlike RSA signatures, ECDSA signatures produced by the Java Cryptography Architecture (JCA) + * are DER-encoded. This method transcodes the DER-encoded signature into the concatenated R|S + * format required by the JWS standard, as specified in RFC 7515 Appendix A.3. + * + * @param privateKey The Elliptic Curve private key used for signing. + * @param jsonFactory The JSON factory to serialize header and payload. + * @param header The JWS header (e.g., containing "alg": "ES256"). + * @param payload The JWS payload containing claims like "iss", "sub", and "aud". + * @return A complete, signed JWS string in the format {@code [header].[payload].[signature]}. + * @throws GeneralSecurityException If signing fails due to cryptographic errors. + * @throws IOException If serialization or transcoding fails. + */ + @VisibleForTesting + private static String signUsingEsSha256( + PrivateKey privateKey, + JsonFactory jsonFactory, + JsonWebSignature.Header header, + JsonWebToken.Payload payload) + throws GeneralSecurityException, IOException { + + // 1. Construct the JWS Signing Input: Base64URL(UTF8(Header)) + '.' + Base64URL(UTF8(Payload)) + String content = + Base64.getUrlEncoder().withoutPadding().encodeToString(jsonFactory.toByteArray(header)) + + "." + + Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(jsonFactory.toByteArray(payload)); + byte[] contentBytes = StringUtils.getBytesUtf8(content); + + // 2. Create the digital signature using SHA256withECDSA. + byte[] signature = + SecurityUtils.sign(SecurityUtils.getEs256SignatureAlgorithm(), privateKey, contentBytes); + + // 3. Transcode the signature from DER to Concatenated R|S. + byte[] jwsSignature = transcodeDerToConcat(signature, 64); + + // 4. Return final JWS: [Signing Input] + '.' + Base64URL(Signature) + return content + "." + Base64.getUrlEncoder().withoutPadding().encodeToString(jwsSignature); + } + + /** + * Transcodes a DER-encoded ECDSA signature into the concatenated R|S format. + * + *

DER format (ASN.1): {@code SEQUENCE { r INTEGER, s INTEGER }} + * + *

Concatenated format: {@code r | s} (where {@code |} is concatenation). + * + * @param derSignature The raw bytes of the DER-encoded signature. + * @param outputLength The total expected length of the concatenated signature (64 bytes for + * ES256). + * @return The signature in concatenated R|S format. + * @throws IOException If the DER format is invalid. + */ + @VisibleForTesting + static byte[] transcodeDerToConcat(byte[] derSignature, int outputLength) throws IOException { + // Validate basic ASN.1 DER structure (0x30 = SEQUENCE) + if (derSignature.length < 8 || derSignature[0] != 0x30) { + throw new IOException("Invalid DER signature format."); + } + + int offset = 2; + int seqLength = derSignature[1] & 0xFF; + // Handle long-form length encoding for the sequence + if (seqLength == 0x81) { + offset = 3; + seqLength = derSignature[2] & 0xFF; + } + + if (derSignature.length - offset != seqLength) { + throw new IOException("Invalid DER signature length."); + } + + // Parse Integer R (0x02 = INTEGER) + if (derSignature[offset++] != 0x02) { + throw new IOException("Expected INTEGER for R."); + } + int rLength = derSignature[offset++]; + // Skip leading zero byte if it exists (DER integers are signed; zero is added to stay positive) + 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; + + // Parse Integer 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); + + // Concatenate r and s into fixed-length segments (32 bytes each for ES256) + 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; + } } diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index f86e3c8d7..b94346a1d 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -86,7 +86,7 @@ public class OAuth2Credentials extends Credentials { // Change listeners are not serialized private transient List 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. diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java index 21278e8b6..3ba798168 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Utils.java @@ -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; @@ -80,6 +79,11 @@ */ public class OAuth2Utils { + public enum Pkcs8Algorithm { + RSA, + EC + } + static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; public static final String TOKEN_TYPE_ACCESS_TOKEN = @@ -267,6 +271,20 @@ static Map validateMap(Map map, String key, Stri * key creation. */ public static PrivateKey privateKeyFromPkcs8(String privateKeyPkcs8) throws IOException { + return privateKeyFromPkcs8(privateKeyPkcs8, Pkcs8Algorithm.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, Pkcs8Algorithm algorithm) + throws IOException { Reader reader = new StringReader(privateKeyPkcs8); Section section = PemReader.readFirstSectionAndClose(reader, "PRIVATE KEY"); if (section == null) { @@ -276,7 +294,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.toString()); return keyFactory.generatePrivate(keySpec); } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) { unexpectedException = exception; diff --git a/oauth2_http/javatests/com/google/auth/TestUtils.java b/oauth2_http/javatests/com/google/auth/TestUtils.java index d794ba184..332961485 100644 --- a/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -116,6 +116,26 @@ public static Map 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 parseBody(String content) throws IOException { + if (content != null && content.trim().startsWith("{")) { + GenericJson json = JSON_FACTORY.fromString(content, GenericJson.class); + Map map = new HashMap<>(); + for (Map.Entry 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); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index 1852629ee..25eec3b82 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -38,6 +38,7 @@ import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import com.google.api.client.http.HttpTransport; import com.google.api.client.http.LowLevelHttpRequest; @@ -94,7 +95,7 @@ class DefaultCredentialsProviderTest { private static final String GDCH_SA_CA_CERT_FILE_NAME = "cert.pem"; private static final String GDCH_SA_CA_CERT_PATH = GdchCredentialsTest.class.getClassLoader().getResource(GDCH_SA_CA_CERT_FILE_NAME).getPath(); - private static final URI GDCH_SA_API_AUDIENCE = URI.create("https://gdch-api-audience"); + private static final String GDCH_SA_API_AUDIENCE = "https://gdch-api-audience"; private static final Collection SCOPES = Collections.singletonList("dummy.scope"); private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private static final String QUOTA_PROJECT = "sample-quota-project-id"; @@ -180,48 +181,36 @@ void getDefaultCredentials_noCredentials_singleGceTestRequest() { } @Test - void getDefaultCredentials_noCredentials_linuxNotGce() throws IOException { - TestDefaultCredentialsProvider testProvider = new TestDefaultCredentialsProvider(); - testProvider.setProperty("os.name", "Linux"); - String productFilePath = SMBIOS_PATH_LINUX; - InputStream productStream = new ByteArrayInputStream("test".getBytes()); - testProvider.addFile(productFilePath, productStream); - - assertFalse(ComputeEngineCredentials.checkStaticGceDetection(testProvider)); + void getDefaultCredentials_noCredentials_linuxNotGce() { + checkStaticGceDetection("Linux", "test", false); } @Test - void getDefaultCredentials_static_linux() throws IOException { - TestDefaultCredentialsProvider testProvider = new TestDefaultCredentialsProvider(); - testProvider.setProperty("os.name", "Linux"); - String productFilePath = SMBIOS_PATH_LINUX; - File productFile = new File(productFilePath); - InputStream productStream = new ByteArrayInputStream("Googlekdjsfhg".getBytes()); - testProvider.addFile(productFile.getAbsolutePath(), productStream); - - assertTrue(ComputeEngineCredentials.checkStaticGceDetection(testProvider)); + void getDefaultCredentials_static_linux() { + checkStaticGceDetection("Linux", "Googlekdjsfhg", true); } @Test - void getDefaultCredentials_static_windows_configuredAsLinux_notGce() throws IOException { - TestDefaultCredentialsProvider testProvider = new TestDefaultCredentialsProvider(); - testProvider.setProperty("os.name", "windows"); - String productFilePath = SMBIOS_PATH_LINUX; - InputStream productStream = new ByteArrayInputStream("Googlekdjsfhg".getBytes()); - testProvider.addFile(productFilePath, productStream); - - assertFalse(ComputeEngineCredentials.checkStaticGceDetection(testProvider)); + void getDefaultCredentials_static_windows_configuredAsLinux_notGce() { + checkStaticGceDetection("windows", "Googlekdjsfhg", false); } @Test - void getDefaultCredentials_static_unsupportedPlatform_notGce() throws IOException { + void getDefaultCredentials_static_unsupportedPlatform_notGce() { + checkStaticGceDetection("macos", "Googlekdjsfhg", false); + } + + private void checkStaticGceDetection(String osName, String productContent, boolean expected) { + assumeTrue( + System.getProperty("os.name").toLowerCase().startsWith("linux"), + "This test only runs on Linux."); TestDefaultCredentialsProvider testProvider = new TestDefaultCredentialsProvider(); - testProvider.setProperty("os.name", "macos"); + testProvider.setProperty("os.name", osName); String productFilePath = SMBIOS_PATH_LINUX; - InputStream productStream = new ByteArrayInputStream("Googlekdjsfhg".getBytes()); + InputStream productStream = new ByteArrayInputStream(productContent.getBytes()); testProvider.addFile(productFilePath, productStream); - assertFalse(ComputeEngineCredentials.checkStaticGceDetection(testProvider)); + assertEquals(expected, ComputeEngineCredentials.checkStaticGceDetection(testProvider)); } @Test diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java index b55514916..4b7b2333d 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java @@ -36,22 +36,33 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import com.google.api.client.json.GenericJson; +import com.google.api.client.json.Json; import com.google.api.client.json.JsonFactory; +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.testing.http.FixedClock; +import com.google.api.client.testing.http.MockLowLevelHttpResponse; import com.google.api.client.util.Clock; import com.google.auth.TestUtils; import com.google.auth.oauth2.GoogleCredentials.GoogleCredentialsInfo; import java.io.File; import java.io.IOException; import java.io.InputStream; +import java.lang.reflect.Method; import java.net.URI; import java.nio.file.Files; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.ECGenParameterSpec; +import java.util.Base64; import java.util.List; import java.util.Map; import org.junit.jupiter.api.Test; @@ -62,17 +73,10 @@ class GdchCredentialsTest extends BaseSerializationTest { private static final String PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; static final String PRIVATE_KEY_PKCS8 = "-----BEGIN PRIVATE KEY-----\n" - + "MIICdgIBADANBgkqhkiG9w0BAQEFAASCAmAwggJcAgEAAoGBALX0PQoe1igW12i" - + "kv1bN/r9lN749y2ijmbc/mFHPyS3hNTyOCjDvBbXYbDhQJzWVUikh4mvGBA07qTj79Xc3yBDfKP2IeyYQIFe0t0" - + "zkd7R9Zdn98Y2rIQC47aAbDfubtkU1U72t4zL11kHvoa0/RuFZjncvlr42X7be7lYh4p3NAgMBAAECgYASk5wDw" - + "4Az2ZkmeuN6Fk/y9H+Lcb2pskJIXjrL533vrDWGOC48LrsThMQPv8cxBky8HFSEklPpkfTF95tpD43iVwJRB/Gr" - + "CtGTw65IfJ4/tI09h6zGc4yqvIo1cHX/LQ+SxKLGyir/dQM925rGt/VojxY5ryJR7GLbCzxPnJm/oQJBANwOCO6" - + "D2hy1LQYJhXh7O+RLtA/tSnT1xyMQsGT+uUCMiKS2bSKx2wxo9k7h3OegNJIu1q6nZ6AbxDK8H3+d0dUCQQDTrP" - + "SXagBxzp8PecbaCHjzNRSQE2in81qYnrAFNB4o3DpHyMMY6s5ALLeHKscEWnqP8Ur6X4PvzZecCWU9BKAZAkAut" - + "LPknAuxSCsUOvUfS1i87ex77Ot+w6POp34pEX+UWb+u5iFn2cQacDTHLV1LtE80L8jVLSbrbrlH43H0DjU5AkEA" - + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" - + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" - + "==\n-----END PRIVATE KEY-----\n"; + + "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgyITXsUvRm1C3lnyz\n" + + "OaMY7TNXZois4NH0bkMwqTAnVbqhRANCAASk5+U9skHVTo+sEVd2/yKY7A2eYn8K\n" + + "Cygd3bQalfWs533aTu93XwVx0YNN310aFquv3/VIiFofm1JEBAhUiG8e\n" + + "-----END PRIVATE KEY-----"; private static final String PROJECT_ID = "project-id"; private static final String SERVICE_IDENTITY_NAME = "service-identity-name"; private static final String ACCESS_TOKEN = "1/MkSJoj1xsli0AccessToken_NKPY2"; @@ -81,7 +85,7 @@ class GdchCredentialsTest extends BaseSerializationTest { private static final String CA_CERT_FILE_NAME = "cert.pem"; private static final String CA_CERT_PATH = GdchCredentialsTest.class.getClassLoader().getResource(CA_CERT_FILE_NAME).getPath(); - private static final URI API_AUDIENCE = URI.create("https://gdch-api-audience"); + private static final String API_AUDIENCE = URI.create("https://gdch-api-audience").toString(); private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); @Test @@ -161,7 +165,7 @@ void fromJSON_nullFormatVersion() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -187,7 +191,7 @@ void fromJSON_nullProjectId() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -213,7 +217,7 @@ void fromJSON_nullPrivateKeyId() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -239,7 +243,7 @@ void fromJSON_nullPrivateKey() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -265,7 +269,7 @@ void fromJSON_nullServiceIdentityName() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -306,7 +310,7 @@ void fromJSON_nullTokenServerUri() throws IOException { null); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -332,7 +336,7 @@ void fromJSON_invalidFormatVersion() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue( @@ -354,7 +358,7 @@ void fromJSON_invalidCaCertPath() throws IOException { TOKEN_SERVER_URI); try { - GdchCredentials credentials = GdchCredentials.fromJson(json); + GdchCredentials.fromJson(json); fail("Should not be able to create GDCH credential without exception."); } catch (IOException ex) { assertTrue(ex.getMessage().contains("Error reading certificate file from CA cert path")); @@ -414,6 +418,77 @@ void fromJSON_hasAccessToken() throws IOException { TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); } + @Test + void fromStream_correct() throws IOException { + InputStream stream = + writeGdchServiceAccountStream( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromStream(stream); + + assertEquals(PROJECT_ID, credentials.getProjectId()); + assertEquals(SERVICE_IDENTITY_NAME, credentials.getServiceIdentityName()); + } + + @Test + void fromStream_invalidType() throws IOException { + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + json.put("type", "invalid_type"); + InputStream stream = TestUtils.jsonToInputStream(json); + + try { + GdchCredentials.fromStream(stream); + fail("Should not be able to create GDCH credential with invalid type."); + } catch (IOException ex) { + assertTrue(ex.getMessage().contains("not recognized")); + } + } + + @Test + void fromStream_withTransportFactory() throws IOException { + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + InputStream stream = + writeGdchServiceAccountStream( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromStream(stream, transportFactory); + + assertEquals(transportFactory, credentials.getTransportFactory()); + } + + @Test + void fromPkcs8_correct() throws IOException { + GdchCredentials.Builder builder = + GdchCredentials.newBuilder() + .setProjectId(PROJECT_ID) + .setPrivateKeyId(PRIVATE_KEY_ID) + .setServiceIdentityName(SERVICE_IDENTITY_NAME) + .setTokenServerUri(TOKEN_SERVER_URI) + .setHttpTransportFactory(new MockTokenServerTransportFactory()); + + GdchCredentials credentials = GdchCredentials.fromPkcs8(PRIVATE_KEY_PKCS8, builder); + assertNotNull(credentials.getPrivateKey()); + assertEquals(PROJECT_ID, credentials.getProjectId()); + } + @Test void createWithGdchAudience_correct() throws IOException { GenericJson json = @@ -439,7 +514,7 @@ void createWithGdchAudience_correct() throws IOException { assertEquals(SERVICE_IDENTITY_NAME, gdchWithAudience.getServiceIdentityName()); assertEquals(TOKEN_SERVER_URI, gdchWithAudience.getTokenServerUri()); assertEquals(CA_CERT_PATH, credentials.getCaCertPath()); - assertEquals(API_AUDIENCE, gdchWithAudience.getApiAudience()); + assertEquals(API_AUDIENCE, gdchWithAudience.getApiAudienceString()); } @Test @@ -456,13 +531,58 @@ void createWithGdchAudience_nullApiAudience() throws IOException { GdchCredentials credentials = GdchCredentials.fromJson(json); try { - GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(null); + credentials.createWithGdchAudience((String) null); fail("Should not be able to create GDCH credential without exception."); - } catch (NullPointerException ex) { + } catch (IllegalArgumentException ex) { assertTrue(ex.getMessage().contains("Audience are not configured for GDCH service account")); } } + @Test + void createWithGdchAudience_emptyApiAudience() throws IOException { + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json); + + try { + credentials.createWithGdchAudience(""); + fail("Should not be able to create GDCH credential without exception."); + } catch (IllegalArgumentException ex) { + assertTrue(ex.getMessage().contains("Audience are not configured for GDCH service account")); + } + } + + @Test + void getApiAudienceString_vs_getApiAudience() throws IOException { + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json); + + String validUri = "https://valid-audience.com"; + GdchCredentials validCredentials = credentials.createWithGdchAudience(validUri); + assertEquals(validUri, validCredentials.getApiAudienceString()); + assertEquals(URI.create(validUri), validCredentials.getApiAudience()); + + String invalidUri = "invalid uri ^"; + GdchCredentials invalidCredentials = credentials.createWithGdchAudience(invalidUri); + assertEquals(invalidUri, invalidCredentials.getApiAudienceString()); + assertNull(invalidCredentials.getApiAudience()); + } + @Test void createAssertion_correct() throws IOException { GenericJson json = @@ -477,7 +597,7 @@ void createAssertion_correct() throws IOException { GdchCredentials credentials = GdchCredentials.fromJson(json); JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; long currentTimeMillis = Clock.SYSTEM.currentTimeMillis(); - String assertion = credentials.createAssertion(jsonFactory, currentTimeMillis, API_AUDIENCE); + String assertion = credentials.createAssertion(jsonFactory, currentTimeMillis); JsonWebSignature signature = JsonWebSignature.parse(jsonFactory, assertion); JsonWebToken.Payload payload = signature.getPayload(); @@ -549,7 +669,7 @@ void refreshAccessToken_nullApiAudience() throws IOException { GdchCredentials.getIssuerSubjectValue(PROJECT_ID, SERVICE_IDENTITY_NAME), tokenString); transportFactory.transport.setTokenServerUri(TOKEN_SERVER_URI); try { - AccessToken accessToken = credentials.refreshAccessToken(); + credentials.refreshAccessToken(); fail("Should not be able to refresh access token without exception."); } catch (NullPointerException ex) { assertTrue( @@ -561,17 +681,7 @@ void refreshAccessToken_nullApiAudience() throws IOException { } @Test - void getIssuerSubjectValue_correct() throws IOException { - GenericJson json = - writeGdchServiceAccountJson( - FORMAT_VERSION, - PROJECT_ID, - PRIVATE_KEY_ID, - PRIVATE_KEY_PKCS8, - SERVICE_IDENTITY_NAME, - CA_CERT_PATH, - TOKEN_SERVER_URI); - GdchCredentials credentials = GdchCredentials.fromJson(json); + void getIssuerSubjectValue_correct() { Object expectedIssSubValue = String.format("system:serviceaccount:%s:%s", PROJECT_ID, SERVICE_IDENTITY_NAME); assertEquals( @@ -771,7 +881,7 @@ void equals_false_tokenServer() throws IOException { @Test void equals_false_apiAudience() throws IOException { - URI otherApiAudience = URI.create("https://foo1.com/bar"); + String otherApiAudience = URI.create("https://foo1.com/bar").toString(); GenericJson json = writeGdchServiceAccountJson( @@ -882,6 +992,276 @@ void serialize_correct() throws IOException, ClassNotFoundException { deserializedCredentials.toBuilder().getHttpTransportFactory().getClass()); } + @Test + void refreshAccessToken_invalidResponse_missingAccessToken() throws IOException { + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json, transportFactory); + GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(API_AUDIENCE); + + transportFactory.transport.addGdchServiceAccount( + GdchCredentials.getIssuerSubjectValue(PROJECT_ID, SERVICE_IDENTITY_NAME), null); + transportFactory.transport.setTokenServerUri(TOKEN_SERVER_URI); + + try { + gdchWithAudience.refreshAccessToken(); + fail("Should not be able to refresh access token without exception."); + } catch (IOException ex) { + assertEquals( + "Error parsing token refresh response. Expected value access_token not found.", + ex.getMessage()); + } + } + + @Test + void refreshAccessToken_invalidResponse_wrongTypeAccessToken() throws IOException { + refreshAccessToken_invalidResponse( + "{\"access_token\": 123, \"expires_in\": 3600}", + "Error parsing token refresh response. Expected string value access_token of wrong type."); + } + + @Test + void refreshAccessToken_invalidResponse_missingExpiresIn() throws IOException { + refreshAccessToken_invalidResponse( + "{\"access_token\": \"token\"}", + "Error parsing token refresh response. Expected value expires_in not found."); + } + + @Test + void refreshAccessToken_invalidResponse_wrongTypeExpiresIn() throws IOException { + refreshAccessToken_invalidResponse( + "{\"access_token\": \"token\", \"expires_in\": \"3600\"}", + "Error parsing token refresh response. Expected integer value expires_in of wrong type."); + } + + private void refreshAccessToken_invalidResponse( + String responseContent, String expectedErrorMessage) throws IOException { + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json, transportFactory); + GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(API_AUDIENCE); + + transportFactory.transport.addResponseSequence( + new MockLowLevelHttpResponse().setContentType(Json.MEDIA_TYPE).setContent(responseContent)); + + try { + gdchWithAudience.refreshAccessToken(); + fail("Should not be able to refresh access token with invalid response."); + } catch (IOException ex) { + assertEquals(expectedErrorMessage, ex.getMessage()); + } + } + + @Test + void refreshAccessToken_serverError() throws IOException { + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json, transportFactory); + GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(API_AUDIENCE); + + transportFactory.transport.addResponseSequence( + new MockLowLevelHttpResponse().setStatusCode(400).setReasonPhrase("Bad Request")); + + try { + gdchWithAudience.refreshAccessToken(); + fail("Should not be able to refresh access token with server error."); + } catch (IOException ex) { + assertTrue(ex.getMessage().contains("Error getting access token for GDCH service account")); + assertTrue(ex.getMessage().contains("400 Bad Request")); + } + } + + @Test + void refreshAccessToken_ioException() throws IOException { + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + GenericJson json = + writeGdchServiceAccountJson( + FORMAT_VERSION, + PROJECT_ID, + PRIVATE_KEY_ID, + PRIVATE_KEY_PKCS8, + SERVICE_IDENTITY_NAME, + CA_CERT_PATH, + TOKEN_SERVER_URI); + GdchCredentials credentials = GdchCredentials.fromJson(json, transportFactory); + GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(API_AUDIENCE); + + transportFactory.transport.addResponseErrorSequence(new IOException("Connection reset")); + + try { + gdchWithAudience.refreshAccessToken(); + fail("Should not be able to refresh access token with IO exception."); + } catch (IOException ex) { + assertTrue(ex.getMessage().contains("Error getting access token for GDCH service account")); + assertTrue(ex.getMessage().contains("Connection reset")); + } + } + + @Test + void transcodeDerToConcat_withGeneratedSignature() throws Exception { + // Generate a new key pair and a signature. + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); + keyGen.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair keyPair = keyGen.generateKeyPair(); + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(keyPair.getPrivate()); + signer.update(new byte[] {1, 2, 3, 4}); + byte[] derSignature = signer.sign(); + + // Transcode the signature and check length. + byte[] jwsSignature = GdchCredentials.transcodeDerToConcat(derSignature, 64); + assertEquals(64, jwsSignature.length); + } + + @Test + void transcodeDerToConcat_invalidDerFormat() { + byte[] invalidDer = new byte[] {0x31, 0x00}; // Not a SEQUENCE + IOException e = + assertThrows(IOException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Invalid DER signature format.", e.getMessage()); + } + + @Test + void transcodeDerToConcat_invalidLength() { + // SEQUENCE length doesn't match actual length + byte[] invalidDer = new byte[] {0x30, 0x05, 0x02, 0x01, 0x01, 0x02, 0x01, 0x02}; + IOException e = + assertThrows(IOException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Invalid DER signature length.", e.getMessage()); + } + + @Test + void transcodeDerToConcat_invalidRInteger() { + // Missing INTEGER for R + byte[] invalidDer = new byte[] {0x30, 0x06, 0x03, 0x01, 0x01, 0x02, 0x01, 0x02}; + IOException e = + assertThrows(IOException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Expected INTEGER for R.", e.getMessage()); + } + + @Test + void transcodeDerToConcat_invalidSInteger() { + // Missing INTEGER for S + byte[] invalidDer = new byte[] {0x30, 0x06, 0x02, 0x01, 0x01, 0x03, 0x01, 0x01}; + IOException e = + assertThrows(IOException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Expected INTEGER for S.", e.getMessage()); + } + + @Test + void signUsingEsSha256_producesVerifiableSignature() throws Exception { + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("EC"); + keyGen.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair keyPair = keyGen.generateKeyPair(); + + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + JsonWebSignature.Header header = new JsonWebSignature.Header(); + header.setAlgorithm("ES256"); + header.setType("JWT"); + header.setKeyId("test-key-id"); + + JsonWebToken.Payload payload = new JsonWebToken.Payload(); + payload.setIssuer("test-issuer"); + payload.setAudience("test-audience"); + + // Use reflection to call the private method. + Method signMethod = + GdchCredentials.class.getDeclaredMethod( + "signUsingEsSha256", + PrivateKey.class, + JsonFactory.class, + JsonWebSignature.Header.class, + JsonWebToken.Payload.class); + signMethod.setAccessible(true); + String signedJws = + (String) signMethod.invoke(null, keyPair.getPrivate(), jsonFactory, header, payload); + + // Verify the signature. + JsonWebSignature jws = JsonWebSignature.parse(jsonFactory, signedJws); + assertTrue(jws.verifySignature(keyPair.getPublic())); + } + + @Test + void signUsingEsSha256_validStructure() throws Exception { + PrivateKey privateKey = + OAuth2Utils.privateKeyFromPkcs8(PRIVATE_KEY_PKCS8, OAuth2Utils.Pkcs8Algorithm.EC); + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); + + JsonWebSignature.Header header = new JsonWebSignature.Header(); + header.setAlgorithm("ES256"); + header.setType("JWT"); + header.setKeyId(PRIVATE_KEY_ID); + + JsonWebToken.Payload payload = new JsonWebToken.Payload(); + payload.setIssuer("test-issuer"); + payload.setAudience("test-audience"); + payload.setSubject("test-subject"); + payload.setIssuedAtTimeSeconds(1000L); + payload.setExpirationTimeSeconds(2000L); + + // Reflectively call the private signUsingEsSha256 method + Method signMethod = + GdchCredentials.class.getDeclaredMethod( + "signUsingEsSha256", + PrivateKey.class, + JsonFactory.class, + JsonWebSignature.Header.class, + JsonWebToken.Payload.class); + signMethod.setAccessible(true); + String signedJws = (String) signMethod.invoke(null, privateKey, jsonFactory, header, payload); + + // Verify JWS structure + String[] parts = signedJws.split("\\."); + assertEquals(3, parts.length); + + // Verify header + JsonWebSignature.Header decodedHeader = + jsonFactory.fromInputStream( + new java.io.ByteArrayInputStream(Base64.getUrlDecoder().decode(parts[0])), + JsonWebSignature.Header.class); + assertEquals("ES256", decodedHeader.getAlgorithm()); + assertEquals("JWT", decodedHeader.getType()); + assertEquals(PRIVATE_KEY_ID, decodedHeader.getKeyId()); + + // Verify payload + JsonWebToken.Payload decodedPayload = + jsonFactory.fromInputStream( + new java.io.ByteArrayInputStream(Base64.getUrlDecoder().decode(parts[1])), + JsonWebToken.Payload.class); + assertEquals("test-issuer", decodedPayload.getIssuer()); + assertEquals("test-audience", decodedPayload.getAudience()); + assertEquals("test-subject", decodedPayload.getSubject()); + + // Verify signature format (64 bytes for ES256) + byte[] signatureBytes = Base64.getUrlDecoder().decode(parts[2]); + assertEquals(64, signatureBytes.length); + } + static GenericJson writeGdchServiceAccountJson( String formatVersion, String project, diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java index 503c87d54..c2678794d 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -82,7 +82,7 @@ class GoogleCredentialsTest extends BaseSerializationTest { private static final String GDCH_SA_CA_CERT_FILE_NAME = "cert.pem"; private static final String GDCH_SA_CA_CERT_PATH = GdchCredentialsTest.class.getClassLoader().getResource(GDCH_SA_CA_CERT_FILE_NAME).getPath(); - private static final URI GDCH_API_AUDIENCE = URI.create("https://gdch-api-audience"); + private static final String GDCH_API_AUDIENCE = "https://gdch-api-audience"; private static final String USER_CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; private static final String USER_CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; private static final String REFRESH_TOKEN = "1/Tl6awhpFjkMkSJoj1xsli0H2eL5YsMgU_NKPY2TyGWY"; @@ -158,7 +158,6 @@ void fromStream_noType_throws() throws IOException { @Test void fromStream_nullStream_throws() { - MockHttpTransportFactory transportFactory = new MockHttpTransportFactory(); assertThrows(NullPointerException.class, () -> GoogleCredentials.parseJsonInputStream(null)); } @@ -782,7 +781,7 @@ void serialize() throws IOException, ClassNotFoundException { } @Test - void toString_containsFields() throws IOException { + void toString_containsFields() { String expectedToString = String.format( "GoogleCredentials{quotaProjectId=%s, universeDomain=%s, isExplicitUniverseDomain=%s}", @@ -793,7 +792,7 @@ void toString_containsFields() throws IOException { } @Test - void hashCode_equals() throws IOException { + void hashCode_equals() { GoogleCredentials credentials = GoogleCredentials.newBuilder().setUniverseDomain("some-domain").build(); GoogleCredentials otherCredentials = @@ -802,7 +801,7 @@ void hashCode_equals() throws IOException { } @Test - void equals_true() throws IOException { + void equals_true() { GoogleCredentials credentials = GoogleCredentials.newBuilder().setUniverseDomain("some-domain").build(); GoogleCredentials otherCredentials = diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java index a61c185b5..0e32c9a2c 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java @@ -213,7 +213,7 @@ public LowLevelHttpResponse execute() throws IOException { } String content = this.getContentAsString(); - Map query = TestUtils.parseQuery(content); + Map query = TestUtils.parseBody(content); String accessToken = null; String refreshToken = null; String grantedScopesString = null; @@ -255,6 +255,9 @@ public LowLevelHttpResponse execute() throws IOException { } else if (query.containsKey("grant_type")) { String grantType = query.get("grant_type"); String assertion = query.get("assertion"); + if (assertion == null) { + assertion = query.get("subject_token"); + } JsonWebSignature signature = JsonWebSignature.parse(JSON_FACTORY, assertion); if (OAuth2Utils.GRANT_TYPE_JWT_BEARER.equals(grantType)) { String foundEmail = signature.getPayload().getIssuer(); @@ -284,7 +287,10 @@ public LowLevelHttpResponse execute() throws IOException { "GDCH Service Account Service Identity Name not found as issuer."); } accessToken = gdchServiceAccounts.get(foundServiceIdentityName); - String foundApiAudience = (String) signature.getPayload().get("api_audience"); + String foundApiAudience = query.get("audience"); + if (foundApiAudience == null || foundApiAudience.isEmpty()) { + foundApiAudience = (String) signature.getPayload().get("api_audience"); + } if ((foundApiAudience == null || foundApiAudience.length() == 0)) { throw new IOException("Api_audience must be specified."); } @@ -326,7 +332,7 @@ public LowLevelHttpResponse execute() throws IOException { new MockLowLevelHttpRequest(url) { @Override public LowLevelHttpResponse execute() throws IOException { - Map parameters = TestUtils.parseQuery(this.getContentAsString()); + Map parameters = TestUtils.parseBody(this.getContentAsString()); String token = parameters.get("token"); if (token == null) { throw new IOException("Token to revoke not found."); @@ -358,7 +364,7 @@ public LowLevelHttpResponse execute() throws IOException { } String content = this.getContentAsString(); - Map query = TestUtils.parseQuery(content); + Map query = TestUtils.parseBody(content); // Validate required fields. if (!query.containsKey("code")