diff --git a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java index a71a8c390..80a3c007b 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; @@ -60,6 +66,7 @@ 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; @@ -67,8 +74,20 @@ public class GdchCredentials extends GoogleCredentials { private static final LoggerProvider LOGGER_PROVIDER = LoggerProvider.forClazz(GdchCredentials.class); - static final String SUPPORTED_FORMAT_VERSION = "1"; private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; + + /** + * The expected format version for GDCH credential profiles. Version "1" indicates the initial and + * currently supported JSON format for these credentials. See go/gdch-python-auth-lib for more + * info. + */ + @VisibleForTesting static final String SUPPORTED_JSON_FORMAT_VERSION = "1"; + + // Custom URN used by GDCH to identify service account tokens in token exchange requests. + // See go/gdch-python-auth-lib for more information. + private static final String SERVICE_ACCOUNT_TOKEN_TYPE = + "urn:k8s:params:oauth:token-type:serviceaccount"; + private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; private final PrivateKey privateKey; @@ -76,7 +95,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; @@ -187,9 +206,9 @@ static GdchCredentials fromJson(Map json, HttpTransportFactory t validateField((String) json.get("token_uri"), "token_uri"); String caCertPath = (String) json.get("ca_cert_path"); - if (!SUPPORTED_FORMAT_VERSION.equals(formatVersion)) { + if (!SUPPORTED_JSON_FORMAT_VERSION.equals(formatVersion)) { throw new IOException( - String.format("Only format version %s is supported.", SUPPORTED_FORMAT_VERSION)); + String.format("Only format version %s is supported.", SUPPORTED_JSON_FORMAT_VERSION)); } URI tokenServerUriFromCreds = null; @@ -214,26 +233,43 @@ 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); + // GDCH key generation natively only supports the EC algorithm. + 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 cannot be null or empty for GDCH service account credentials."); + } return this.toBuilder().setGdchAudience(apiAudience).build(); } @@ -248,17 +284,22 @@ public GdchCredentials createWithGdchAudience(URI apiAudience) throws IOExceptio public AccessToken refreshAccessToken() throws IOException { Preconditions.checkNotNull( this.apiAudience, - "Audience are not configured for GDCH service account. Specify the " - + "audience by calling createWithGDCHAudience."); + "Audience cannot be null or empty for GDCH service account credentials. " + + "Specify the audience by calling createWithGdchAudience."); + + JsonFactory jsonFactory = GsonFactory.getDefaultInstance(); - JsonFactory jsonFactory = OAuth2Utils.JSON_FACTORY; - long currentTime = clock.currentTimeMillis(); - String assertion = createAssertion(jsonFactory, currentTime, getApiAudience()); + long currentTime = Clock.SYSTEM.currentTimeMillis(); + String assertion = createAssertion(jsonFactory, currentTime); GenericData tokenRequest = new GenericData(); + tokenRequest.set("audience", apiAudience); tokenRequest.set("grant_type", OAuth2Utils.TOKEN_TYPE_TOKEN_EXCHANGE); - tokenRequest.set("assertion", assertion); - UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + tokenRequest.set("requested_token_type", OAuth2Utils.TOKEN_TYPE_ACCESS_TOKEN); + 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); @@ -280,8 +321,8 @@ public AccessToken refreshAccessToken() throws IOException { String message = String.format(errorTemplate, re.getMessage(), getServiceIdentityName()); throw GoogleAuthException.createWithTokenEndpointResponseException(re, message); } catch (IOException e) { - throw GoogleAuthException.createWithTokenEndpointIOException( - e, String.format(errorTemplate, e.getMessage(), getServiceIdentityName())); + String message = String.format(errorTemplate, e.getMessage(), getServiceIdentityName()); + throw GoogleAuthException.createWithTokenEndpointIOException(e, message); } GenericData responseData = response.parseAs(GenericData.class); @@ -302,10 +343,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); @@ -314,15 +354,14 @@ 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); + throw new GoogleAuthException( + false, 0, "Error signing service account access token request with private key.", e); } return assertion; @@ -363,10 +402,35 @@ public final URI getTokenServerUri() { return tokenServerUri; } - public final URI getApiAudience() { + /** + * Returns the underlying audience string set for this credentials object. + * + * @return the audience string, or null if no audience has been set. + */ + public final String getGdchAudience() { return apiAudience; } + /** + * NOTE: This method is obsolete, please use {@link #getGdchAudience()} 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, or null if no audience has + * been set or if the audience string is not a valid URI. + */ + @ObsoleteApi("Use getGdchAudience() 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; } @@ -446,7 +510,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; @@ -506,8 +570,19 @@ public Builder setCaCertPath(String caCertPath) { return this; } + /** + * Sets the intended audience for GDCH credentials. + * + * @param apiAudience The audience string. Cannot be null or empty. + * @return this builder. + * @throws IllegalArgumentException if the audience is null or empty. + */ @CanIgnoreReturnValue - public Builder setGdchAudience(URI apiAudience) { + public Builder setGdchAudience(String apiAudience) { + if (Strings.isNullOrEmpty(apiAudience)) { + throw new IllegalArgumentException( + "Audience cannot be null or empty for GDCH service account credentials."); + } this.apiAudience = apiAudience; return this; } @@ -563,13 +638,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 { @@ -604,4 +682,136 @@ private void setTransport(String caCertPath) throws IOException { } } } + + /** + * 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 + static String signUsingEsSha256( + PrivateKey privateKey, + JsonFactory jsonFactory, + JsonWebSignature.Header header, + JsonWebToken.Payload payload) + throws GeneralSecurityException, GoogleAuthException { + + try { + // 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); + } catch (IOException e) { + throw new GoogleAuthException(false, 0, "Error serializing or transcoding JWT.", e); + } + } + + /** + * 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 GoogleAuthException { + // Validate basic ASN.1 DER structure (0x30 = SEQUENCE) + if (derSignature.length < 8 || derSignature[0] != 0x30) { + throw new GoogleAuthException(false, 0, "Invalid DER signature format.", null); + } + + 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 GoogleAuthException(false, 0, "Invalid DER signature length.", null); + } + + // Parse Integer R (0x02 = INTEGER) + if (derSignature[offset++] != 0x02) { + throw new GoogleAuthException(false, 0, "Expected INTEGER for R.", null); + } + 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 GoogleAuthException(false, 0, "Expected INTEGER for S.", null); + } + 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 GoogleAuthException( + false, + 0, + String.format( + "Invalid R or S length. R: %d, S: %d, Expected: %d", + r.length, s.length, keySizeBytes), + null); + } + + 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 b4a933963..e17714c3e 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 7efec082f..6f2a4eeb9 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.api.core.InternalApi; import com.google.auth.http.AuthHttpConstants; import com.google.auth.http.HttpTransportFactory; @@ -82,6 +81,11 @@ @InternalApi public class OAuth2Utils { + enum Pkcs8Algorithm { + RSA, + EC + } + static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; public static final String TOKEN_TYPE_ACCESS_TOKEN = @@ -269,6 +273,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) { @@ -278,7 +296,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..ff42ab872 100644 --- a/oauth2_http/javatests/com/google/auth/TestUtils.java +++ b/oauth2_http/javatests/com/google/auth/TestUtils.java @@ -101,6 +101,13 @@ public static InputStream stringToInputStream(String text) { } } + /** + * Parses a URI query string into a map of key-value pairs. + * + * @param query The URI query string (e.g., "key1=val1&key2=val2"). + * @return A map of decoded keys to decoded values. + * @throws IOException If the query string is malformed. + */ public static Map parseQuery(String query) throws IOException { Map map = new HashMap<>(); Iterable entries = Splitter.on('&').split(query); @@ -116,6 +123,23 @@ public static Map parseQuery(String query) throws IOException { return map; } + /** + * Parses a JSON string into a map of key-value pairs. + * + * @param content The JSON string representation of a flat object. + * @return A map of keys to string representations of their values. + * @throws IOException If the JSON is malformed. + */ + public static Map parseJson(String content) throws IOException { + 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; + } + 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..bdf3f1b20 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -83,7 +83,7 @@ class DefaultCredentialsProviderTest { private static final String SA_PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; private static final String SA_PRIVATE_KEY_PKCS8 = ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8; - private static final String GDCH_SA_FORMAT_VERSION = GdchCredentials.SUPPORTED_FORMAT_VERSION; + private static final String GDCH_SA_PROJECT_ID = "gdch-service-account-project-id"; private static final String GDCH_SA_PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; private static final String GDCH_SA_PRIVATE_KEY_PKC8 = GdchCredentialsTest.PRIVATE_KEY_PKCS8; @@ -94,7 +94,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 +180,33 @@ 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) { 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 @@ -390,7 +375,7 @@ void getDefaultCredentials_GdchServiceAccount() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, diff --git a/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java index b55514916..1f4e8b514 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GdchCredentialsTest.java @@ -36,14 +36,17 @@ 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; @@ -52,27 +55,26 @@ import java.io.InputStream; 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; /** Test case for {@link GdchCredentials}. */ class GdchCredentialsTest extends BaseSerializationTest { - private static final String FORMAT_VERSION = GdchCredentials.SUPPORTED_FORMAT_VERSION; + private static final String FORMAT_VERSION = GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION; 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 +83,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 = "https://gdch-api-audience"; private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); @Test @@ -160,18 +162,14 @@ void fromJSON_nullFormatVersion() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "format_version"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "format_version"))); } @Test @@ -186,18 +184,14 @@ void fromJSON_nullProjectId() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "project"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "project"))); } @Test @@ -212,18 +206,14 @@ void fromJSON_nullPrivateKeyId() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "private_key_id"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "private_key_id"))); } @Test @@ -238,18 +228,14 @@ void fromJSON_nullPrivateKey() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "private_key"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "private_key"))); } @Test @@ -264,18 +250,14 @@ void fromJSON_nullServiceIdentityName() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "name"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "name"))); } @Test @@ -305,18 +287,14 @@ void fromJSON_nullTokenServerUri() throws IOException { CA_CERT_PATH, null); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains( - String.format( - "Error reading GDCH service account credential from JSON, " - + "%s is misconfigured.", - "token_uri"))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains( + String.format( + "Error reading GDCH service account credential from JSON, " + + "%s is misconfigured.", + "token_uri"))); } @Test @@ -331,14 +309,10 @@ void fromJSON_invalidFormatVersion() throws IOException { CA_CERT_PATH, TOKEN_SERVER_URI); - try { - GdchCredentials credentials = GdchCredentials.fromJson(json); - fail("Should not be able to create GDCH credential without exception."); - } catch (IOException ex) { - assertTrue( - ex.getMessage() - .contains(String.format("Only format version %s is supported", FORMAT_VERSION))); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue( + ex.getMessage() + .contains(String.format("Only format version %s is supported", FORMAT_VERSION))); } @Test @@ -353,12 +327,8 @@ void fromJSON_invalidCaCertPath() throws IOException { "/path/to/missing/file", TOKEN_SERVER_URI); - try { - GdchCredentials credentials = 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")); - } + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromJson(json)); + assertTrue(ex.getMessage().contains("Error reading certificate file from CA cert path")); } @Test @@ -414,6 +384,73 @@ 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); + + IOException ex = assertThrows(IOException.class, () -> GdchCredentials.fromStream(stream)); + 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 +476,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.getGdchAudience()); } @Test @@ -455,12 +492,55 @@ void createWithGdchAudience_nullApiAudience() throws IOException { TOKEN_SERVER_URI); GdchCredentials credentials = GdchCredentials.fromJson(json); - try { - GdchCredentials gdchWithAudience = credentials.createWithGdchAudience(null); - fail("Should not be able to create GDCH credential without exception."); - } catch (NullPointerException ex) { - assertTrue(ex.getMessage().contains("Audience are not configured for GDCH service account")); - } + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> credentials.createWithGdchAudience((String) null)); + assertTrue( + ex.getMessage().contains("Audience cannot be null or empty 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); + + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> credentials.createWithGdchAudience("")); + assertTrue( + ex.getMessage().contains("Audience cannot be null or empty for GDCH service account")); + } + + @Test + void getGdchAudience_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.getGdchAudience()); + assertEquals(URI.create(validUri), validCredentials.getApiAudience()); + + String invalidUri = "invalid uri ^"; + GdchCredentials invalidCredentials = credentials.createWithGdchAudience(invalidUri); + assertEquals(invalidUri, invalidCredentials.getGdchAudience()); + assertNull(invalidCredentials.getApiAudience()); } @Test @@ -477,7 +557,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(); @@ -548,30 +628,17 @@ void refreshAccessToken_nullApiAudience() throws IOException { transportFactory.transport.addGdchServiceAccount( GdchCredentials.getIssuerSubjectValue(PROJECT_ID, SERVICE_IDENTITY_NAME), tokenString); transportFactory.transport.setTokenServerUri(TOKEN_SERVER_URI); - try { - AccessToken accessToken = credentials.refreshAccessToken(); - fail("Should not be able to refresh access token without exception."); - } catch (NullPointerException ex) { - assertTrue( - ex.getMessage() - .contains( - "Audience are not configured for GDCH service account. Specify the " - + "audience by calling createWithGDCHAudience")); - } + NullPointerException ex = + assertThrows(NullPointerException.class, () -> credentials.refreshAccessToken()); + assertTrue( + ex.getMessage() + .contains( + "Audience cannot be null or empty for GDCH service account credentials. " + + "Specify the audience by calling createWithGdchAudience")); } @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 +838,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 +949,258 @@ 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); + + IOException ex = assertThrows(IOException.class, () -> gdchWithAudience.refreshAccessToken()); + 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)); + + IOException ex = assertThrows(IOException.class, () -> gdchWithAudience.refreshAccessToken()); + 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")); + + GoogleAuthException ex = + assertThrows(GoogleAuthException.class, () -> gdchWithAudience.refreshAccessToken()); + 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")); + + GoogleAuthException ex = + assertThrows(GoogleAuthException.class, () -> gdchWithAudience.refreshAccessToken()); + 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 + GoogleAuthException e = + assertThrows( + GoogleAuthException.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}; + GoogleAuthException e = + assertThrows( + GoogleAuthException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Invalid DER signature length.", e.getMessage()); + } + + @Test + void transcodeDerToConcat_invalidRInteger() { + // Uses BIT STRING (0x03) instead of INTEGER (0x02) for R + byte[] invalidDer = new byte[] {0x30, 0x06, 0x03, 0x01, 0x01, 0x02, 0x01, 0x02}; + GoogleAuthException e = + assertThrows( + GoogleAuthException.class, () -> GdchCredentials.transcodeDerToConcat(invalidDer, 64)); + assertEquals("Expected INTEGER for R.", e.getMessage()); + } + + @Test + void transcodeDerToConcat_invalidSInteger() { + // Uses BIT STRING (0x03) instead of INTEGER (0x02) for S + byte[] invalidDer = new byte[] {0x30, 0x06, 0x02, 0x01, 0x01, 0x03, 0x01, 0x01}; + GoogleAuthException e = + assertThrows( + GoogleAuthException.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"); + + String signedJws = + GdchCredentials.signUsingEsSha256(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); + + String signedJws = GdchCredentials.signUsingEsSha256(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); + } + + @Test + void builder_setGdchAudience_nullString() { + GdchCredentials.Builder builder = GdchCredentials.newBuilder(); + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> builder.setGdchAudience((String) null)); + assertTrue( + ex.getMessage() + .contains("Audience cannot be null or empty for GDCH service account credentials.")); + } + 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..275e419fd 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/GoogleCredentialsTest.java @@ -71,7 +71,7 @@ class GoogleCredentialsTest extends BaseSerializationTest { private static final String SA_PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; private static final String SA_PRIVATE_KEY_PKCS8 = ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8; - private static final String GDCH_SA_FORMAT_VERSION = GdchCredentials.SUPPORTED_FORMAT_VERSION; + private static final String GDCH_SA_PROJECT_ID = "gdch-service-account-project-id"; private static final String GDCH_SA_PRIVATE_KEY_ID = "d84a4fefcf50791d4a90f2d7af17469d6282df9d"; private static final String GDCH_SA_PRIVATE_KEY_PKC8 = GdchCredentialsTest.PRIVATE_KEY_PKCS8; @@ -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)); } @@ -245,7 +244,7 @@ void fromStream_gdchServiceAccount_correct() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, @@ -295,7 +294,7 @@ void fromStream_gdchServiceAccountNoFormatVersion_throws() throws IOException { void fromStream_gdchServiceAccountNoProjectId_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, null, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, @@ -310,7 +309,7 @@ void fromStream_gdchServiceAccountNoProjectId_throws() throws IOException { void fromStream_gdchServiceAccountNoPrivateKeyId_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, null, GDCH_SA_PRIVATE_KEY_PKC8, @@ -325,7 +324,7 @@ void fromStream_gdchServiceAccountNoPrivateKeyId_throws() throws IOException { void fromStream_gdchServiceAccountNoPrivateKey_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, null, @@ -340,7 +339,7 @@ void fromStream_gdchServiceAccountNoPrivateKey_throws() throws IOException { void fromStream_gdchServiceAccountNoServiceIdentityName_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, @@ -355,7 +354,7 @@ void fromStream_gdchServiceAccountNoServiceIdentityName_throws() throws IOExcept void fromStream_gdchServiceAccountNoTokenServerUri_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, @@ -380,14 +379,15 @@ void fromStream_gdchServiceAccountInvalidFormatVersion_throws() throws IOExcepti testFromStreamException( gdchServiceAccountStream, - String.format("Only format version %s is supported", GDCH_SA_FORMAT_VERSION)); + String.format( + "Only format version %s is supported", GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION)); } @Test void fromStream_gdchServiceAccountInvalidCaCertPath_throws() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, @@ -782,7 +782,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 +793,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 +802,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 = @@ -854,7 +854,7 @@ void getCredentialInfo_userCredentials() throws IOException { void getCredentialInfo_gdchCredentials() throws IOException { InputStream gdchServiceAccountStream = GdchCredentialsTest.writeGdchServiceAccountStream( - GDCH_SA_FORMAT_VERSION, + GdchCredentials.SUPPORTED_JSON_FORMAT_VERSION, GDCH_SA_PROJECT_ID, GDCH_SA_PRIVATE_KEY_ID, GDCH_SA_PRIVATE_KEY_PKC8, diff --git a/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java b/oauth2_http/javatests/com/google/auth/oauth2/MockTokenServerTransport.java index a61c185b5..0c4098983 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 = parseRequestContent(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 = parseRequestContent(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 = parseRequestContent(content); // Validate required fields. if (!query.containsKey("code") @@ -414,6 +420,13 @@ public LowLevelHttpResponse execute() throws IOException { return super.buildRequest(method, url); } + private Map parseRequestContent(String content) throws IOException { + if (content != null && content.trim().startsWith("{")) { + return TestUtils.parseJson(content); + } + return TestUtils.parseQuery(content); + } + private void validateAdditionalParameters(Map query) { if (additionalParameters.containsKey(query.get("code"))) { Map additionalParametersMap = additionalParameters.get(query.get("code"));