diff --git a/gapic-libraries-bom/pom.xml b/gapic-libraries-bom/pom.xml index 1f255857e08e..6cdce225cf03 100644 --- a/gapic-libraries-bom/pom.xml +++ b/gapic-libraries-bom/pom.xml @@ -301,13 +301,6 @@ pom import - - com.google.cloud - google-cloud-bigtable-deps-bom - 2.78.1-SNAPSHOT - pom - import - com.google.cloud google-cloud-billing-bom diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java new file mode 100644 index 000000000000..2bb2f4cad9e1 --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -0,0 +1,640 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.auth.oauth2; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.api.core.InternalApi; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** Utility class for Agent Identity token binding in Cloud Run. */ +@InternalApi +public final class AgentIdentityUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); + + // Environment variables + static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; + static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + + private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = + ImmutableList.of( + Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + + private static final int SAN_URI_TYPE = 6; + private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + + private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + + @VisibleForTesting + static void setWellKnownDir(String dir) { + wellKnownDir = dir; + } + + // Polling configuration + private static final int CERT_KEY_MATCH_RETRIES = 3; + private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; + + private static final int FAST_POLL_CYCLES = 50; + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + private static final List POLLING_INTERVALS; + + static { + List intervals = new ArrayList<>(); + for (int i = 0; i < FAST_POLL_CYCLES; i++) { + intervals.add(FAST_POLL_INTERVAL_MS); + } + long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); + int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); + for (int i = 0; i < slowPollCycles; i++) { + intervals.add(SLOW_POLL_INTERVAL_MS); + } + POLLING_INTERVALS = Collections.unmodifiableList(intervals); + } + + public interface EnvReader { + String getEnv(String name); + } + + private static EnvReader envReader = System::getenv; + + @VisibleForTesting + interface TimeService { + long currentTimeMillis(); + + void sleep(long millis) throws InterruptedException; + } + + private static TimeService timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + + private AgentIdentityUtils() {} + + static class CertInfo { + final X509Certificate certificate; + final String certContent; + + CertInfo(X509Certificate certificate, String certContent) { + this.certificate = certificate; + this.certContent = certContent; + } + } + + static class ResolvedCertAndKeyPaths { + final String certPath; + final String keyPath; + + ResolvedCertAndKeyPaths(String certPath, String keyPath) { + this.certPath = certPath; + this.keyPath = keyPath; + } + } + + /** + * Retrieves the certificate and path for the Agent Identity. + * + *

This method attempts to load the certificate and private key for the agent identity. It + * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment + * variable. If not set, it falls back to well-known default locations. + * + *

To handle transient race conditions during certificate rotation on disk, this method employs + * a retry mechanism with backoff when reading the configuration and certificate files. + * + * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code + * null} if the agent identity features are disabled, opted out, or if no valid credentials + * could be loaded. + * @throws IOException If an I/O error occurs while reading the files, or if the key-pair + * verification fails after retries. + */ + static CertInfo getAgentIdentityCertInfo() throws IOException { + if (!isTokenBindingEnabled()) { + return null; + } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + + ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); + boolean certsPresent = !Strings.isNullOrEmpty(paths.certPath); + + if (!shouldEnableMtls(certsPresent, configExists)) { + return null; + } + + return loadAndVerifyCredentials(paths.certPath, paths.keyPath); + } + + /** + * Resolves the paths for the certificate and private key based on the config path or well-known + * locations. + */ + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { + String certPath = null; + String keyPath = null; + + if (!Strings.isNullOrEmpty(certConfigPath)) { + java.nio.file.Path configPath = Paths.get(certConfigPath); + if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we are not in a workload environment + return new ResolvedCertAndKeyPaths(null, null); + } + // Read cert and key paths from config file. We use retry with backoff to handle transient + // race conditions where the config file might be being updated by a rotation process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + certPath = paths.certPath; + keyPath = paths.keyPath; + } + } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. + certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + + /** + * Loads the certificate and private key, and verifies that they match if they are separate files. + */ + static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + X509Certificate cert = null; + PrivateKey privateKey = null; + String certContent = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < CERT_KEY_MATCH_RETRIES) { + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate or key files. Falling back to unbound token."); + return null; + } catch (Exception e) { + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); + } + + retries++; + if (retries < CERT_KEY_MATCH_RETRIES) { + try { + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } + } + } + + if (!matched) { + throw new IOException( + String.format( + "Agent Identity certificate and private key mismatch or read failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate files. Falling back to unbound token."); + return null; + } + } + + return new CertInfo(cert, certContent); + } + + /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + throws java.nio.file.AccessDeniedException { + try { + Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); + return true; + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (IOException e) { + return false; + } + } + + /** Checks if the user has disabled token binding by setting the environment variable to false. */ + private static boolean isTokenBindingEnabled() { + String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return !("false".equalsIgnoreCase(preventSharing)); + } + + /** + * Reads the certificate path from the config file with retry logic to handle rotation race + * conditions. + */ + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (paths != null + && !Strings.isNullOrEmpty(paths.certPath) + && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { + return paths; + } + } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate config file. Falling back to unbound token."); + return null; + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found or invalid at %s (from %s environment variable)." + + " Retrying for up to %d seconds.", + certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound token" + + " request.", + e); + } + } + throw new IOException( + "Unable to find Agent Identity certificate config or file for bound token request after" + + " multiple retries. Token binding protection is failing. You can turn off this" + + " protection by setting " + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); + } + + /** Searches for certificates at well-known locations with retry logic. */ + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { + return bundlePath; + } + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound token."); + return null; + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for well-known certificate files.", e); + } + } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple" + + " retries."); + } + + /** Reads the full certificate chain from the specified path as a string. */ + static String readCertificateChain(String certPath) throws IOException { + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + } + + /** + * Verifies that the private key corresponds to the public key in the certificate by performing a + * test signature and verification. + */ + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + LOGGER.warn("Key pair verification failed", e); + return false; + } + } + + /** Reads the private key from the specified path using PKCS8 format. */ + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); + } + + /** + * Determines if mTLS should be enabled based on environment variables and certificate presence. + */ + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + // Case 1: Explicitly enabled via environment variable + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Certs are available, enable mTLS + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent established via config, but cert files are missing."); + } + // Neither exist, do not enable + return false; + } + // Case 2: Explicitly disabled via environment variable + else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Warn that we are ignoring present certs because it was explicitly disabled + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled via" + + " GOOGLE_API_USE_CLIENT_CERTIFICATE."); + return false; + } + return false; + } + // Case 3: Environment variable is unset + else { + if (certsPresent) { + // Infer mTLS is enabled because certs are present + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); + } + // Neither cert-config nor certs exist, do not enable + return false; + } + } + + /** Retrieves the bound token payload (certificate chain) if applicable. */ + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.certificate)) { + return info.certContent; + } + return null; + } + + @SuppressWarnings("unchecked") + /** Extracts the certificate and private key paths from the JSON configuration file. */ + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + throws IOException { + try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Object certConfigsObj = config.get("cert_configs"); + if (certConfigsObj instanceof Map) { + Map certConfigs = (Map) certConfigsObj; + Object workloadObj = certConfigs.get("workload"); + if (workloadObj instanceof Map) { + Map workload = (Map) workloadObj; + String certPath = null; + String keyPath = null; + if (workload.get("cert_path") instanceof String) { + certPath = (String) workload.get("cert_path"); + } + if (workload.get("key_path") instanceof String) { + keyPath = (String) workload.get("key_path"); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + } + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to parse Agent Identity config JSON", e); + } + return null; + } + + /** Parses the X509 certificate from the specified content string. */ + private static X509Certificate parseCertificateContent(String certContent) throws IOException { + try (InputStream stream = + new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } + } + + /** + * Determines if a bound token should be requested by checking if any of the certificate's Subject + * Alternative Names (SANs) match allowed SPIFFE patterns. + */ + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + // Iterate through all Subject Alternative Names + for (List san : sans) { + // Check if the SAN entry is a URI (type 6) + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + // Check if the URI starts with "spiffe://" + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + // Extract the trust domain (part before the first slash) + String trustDomain = + (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + // Match the trust domain against allowed agent patterns + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } + } + } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + } + return false; + } + + @VisibleForTesting + public static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index ad5fb8e7dcf3..7dfa72bdeef0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -61,6 +61,7 @@ import java.io.ObjectInputStream; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -317,7 +318,7 @@ public String getUniverseDomain() throws IOException { private String getUniverseDomainFromMetadata() throws IOException { HttpResponse response = - getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false); + getMetadataResponse(getUniverseDomainUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { return Credentials.GOOGLE_DEFAULT_UNIVERSE; @@ -376,7 +377,8 @@ public String getProjectId() { private String getProjectIdFromMetadata() { try { - HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false); + HttpResponse response = + getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { LoggingUtils.log( @@ -418,8 +420,21 @@ private String getProjectIdFromMetadata() { /** Refresh the access token by getting it from the GCE metadata server */ @Override public AccessToken refreshAccessToken() throws IOException { - HttpResponse response = - getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true); + String tokenUrl = createTokenUrlWithScopes(); + + String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); + HttpResponse response; + + if (boundTokenPayload != null) { + java.util.Map payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); + } else { + response = getMetadataResponse(tokenUrl, "GET", null, RequestType.ACCESS_TOKEN_REQUEST, true); + } int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( @@ -487,8 +502,22 @@ public IdToken idTokenWithAudience(String targetAudience, List payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse( + documentUrl.toString(), "POST", jsonString, RequestType.ID_TOKEN_REQUEST, true); + } else { + response = + getMetadataResponse( + documentUrl.toString(), "GET", null, RequestType.ID_TOKEN_REQUEST, true); + } int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( @@ -517,10 +546,26 @@ public IdToken idTokenWithAudience(String targetAudience, List= 400 && statusCode < 500) { + return false; + } + } LOGGER.log( Level.FINE, "Encountered an unexpected exception when checking" @@ -827,7 +882,8 @@ public byte[] sign(byte[] toSign) { private String getDefaultServiceAccount() throws IOException { HttpResponse response = - getMetadataResponse(getDefaultServiceAccountUrl(), RequestType.UNTRACKED, false); + getMetadataResponse( + getDefaultServiceAccountUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java new file mode 100644 index 000000000000..d960aed73022 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -0,0 +1,364 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AgentIdentityUtilsTest { + + private static final String VALID_SPIFFE_ORG = + "spiffe://agents.global.org-12345.system.id.goog/path/to/resource"; + private static final String VALID_SPIFFE_PROJ = + "spiffe://agents.global.proj-98765.system.id.goog/another/path"; + private static final String INVALID_SPIFFE_DOMAIN = "spiffe://example.com/workload"; + private static final String INVALID_SPIFFE_FORMAT = + "spiffe://agents.global.org-INVALID.system.id.goog/path"; + + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("agent_identity_test"); + } + + @AfterEach + void tearDown() throws IOException { + AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setWellKnownDir("/var/run/secrets/workload-spiffe-credentials/"); + AgentIdentityUtils.setEnvReader(System::getenv); + if (tempDir != null) { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + @Test + public void shouldRequestBoundToken_validOrgSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_ORG))); + } + + @Test + public void shouldRequestBoundToken_validProjSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_PROJ))); + } + + @Test + public void shouldRequestBoundToken_invalidDomain_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_DOMAIN))); + } + + @Test + public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_FORMAT))); + } + + @Test + public void shouldRequestBoundToken_certificateParsingException_returnsFalse() throws java.security.cert.CertificateParsingException { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenThrow(new java.security.cert.CertificateParsingException()); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + @Test + public void shouldRequestBoundToken_nonUriSan_returnsFalse() throws java.security.cert.CertificateParsingException { + X509Certificate mockCert = mock(X509Certificate.class); + List dnsSan = Arrays.asList(2, "www.example.com"); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Collections.>singleton(dnsSan)); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + @Test + public void shouldRequestBoundToken_noSan_returnsFalse() throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenReturn(null); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + private X509Certificate mockCertWithSanUri(String uri) throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + List spiffeEntry = Arrays.asList(6, uri); + Collection> sans = Collections.singletonList(spiffeEntry); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + return mockCert; + } + + @Test + public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { + AgentIdentityUtils.setTimeService(new FakeTimeService()); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException { + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write(configJson.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertTrue(info.certificate.getIssuerDN().getName().contains("unit-tests")); + } + + @Test + public void getAgentIdentityCertificate_timeout_throwsIOException() { + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + tempDir.resolve("missing.json").toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); + } + + @Test + public void getAgentIdentityCertInfo_malformedJson_throwsIOException() throws IOException { + File configFile = tempDir.resolve("config.json").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write("{ \"cert_configs\": invalid json} ".getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); + } + + @Test + public void shouldEnableMtls_true_certsPresent_returnsTrue() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void shouldEnableMtls_true_certsMissing_throwsIOException() { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + assertThrows(IOException.class, () -> AgentIdentityUtils.shouldEnableMtls(false, true)); + } + + @Test + public void shouldEnableMtls_false_certsPresent_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"); + assertFalse(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void shouldEnableMtls_unset_certsPresent_returnsTrue() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOException { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + + Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals( + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), + info.certContent); + } + + @Test + public void verifyKeyPair_match_returnsTrue() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp.getPublic()); + + assertTrue(AgentIdentityUtils.verifyKeyPair(mockCert, kp.getPrivate())); + } + + @Test + public void verifyKeyPair_mismatch_returnsFalse() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp1 = kpg.generateKeyPair(); + KeyPair kp2 = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp1.getPublic()); + + assertFalse(AgentIdentityUtils.verifyKeyPair(mockCert, kp2.getPrivate())); + } + + @Test + public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() throws Exception { + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + + // Generate a random key that won't match the cert + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + File keyFile = tempDir.resolve("private_key.pem").toFile(); + String keyPem = + "-----BEGIN PRIVATE KEY-----\n" + + java.util.Base64.getMimeEncoder().encodeToString(kp.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + try (FileOutputStream fos = new FileOutputStream(keyFile)) { + fos.write(keyPem.getBytes(StandardCharsets.UTF_8)); + } + + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"," + + " \"key_path\": \"" + + keyFile.getAbsolutePath().replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write(configJson.getBytes(StandardCharsets.UTF_8)); + } + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Agent Identity certificate and private key mismatch or read failure after 3" + + " retries.")); + assertEquals(200, fakeTime.currentTimeMillis()); // 2 retries * 100ms + } + + private static class FakeTimeService implements AgentIdentityUtils.TimeService { + private final AtomicLong currentTime = new AtomicLong(0); + + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + currentTime.addAndGet(millis); + } + } + + private static class TestEnvironmentProvider { + private final java.util.Map env = new java.util.HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 82240171d9af..ff30522d7186 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -60,16 +60,23 @@ import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.IOException; import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collection; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link ComputeEngineCredentials}. */ @@ -77,6 +84,41 @@ class ComputeEngineCredentialsTest extends BaseSerializationTest { private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + // Inject our test environment reader into AgentIdentityUtils + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("compute_engine_creds_test"); + + // Speed up polling in tests by using a fake time service that advances time immediately + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + // Opt out of bound tokens by default in tests to avoid polling delays + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + } + + @AfterEach + void tearDown() { + // Reset the mocks + AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; @@ -491,7 +533,8 @@ void toString_explicit_containsFields() { new MockMetadataServerTransportFactory(); String expectedToString = String.format( - "ComputeEngineCredentials{quotaProjectId=%s, universeDomain=%s, isExplicitUniverseDomain=%s, transportFactoryClassName=%s, scopes=%s}", + "ComputeEngineCredentials{quotaProjectId=%s, universeDomain=%s," + + " isExplicitUniverseDomain=%s, transportFactoryClassName=%s, scopes=%s}", "some-project", "some-domain", true, @@ -1177,6 +1220,133 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } + @Test + void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IOException e = assertThrows(IOException.class, credentials::refreshAccessToken); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); + } + + private void setupCertAndKeyConfig() throws IOException { + java.nio.file.Path certSource = null; + try { + certSource = java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_cert.pem") + .toURI()); + } catch (java.net.URISyntaxException e) { + throw new IOException("Failed to load test resource", e); + } + java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); + Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + java.nio.file.Path keySource = null; + try { + keySource = java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_key.pem") + .toURI()); + } catch (java.net.URISyntaxException e) { + throw new IOException("Failed to load test resource", e); + } + java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); + Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + Path configPath = tempDir.resolve("config.json"); + Map workload = new HashMap<>(); + workload.put("cert_path", certTarget.toAbsolutePath().toString()); + workload.put("key_path", keyTarget.toAbsolutePath().toString()); + + Map certConfigs = new HashMap<>(); + certConfigs.put("workload", workload); + + Map config = new HashMap<>(); + config.put("cert_configs", certConfigs); + + String configContent = OAuth2Utils.JSON_FACTORY.toString(config); + Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); + } + + @Test + void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + + @Test + void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IdToken token = credentials.idTokenWithAudience("https://foo.bar", null); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + static class MockMetadataServerTransportFactory implements HttpTransportFactory { MockMetadataServerTransport transport = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index d0447871b01a..389ac03414c4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -67,11 +67,31 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link DefaultCredentialsProvider}. */ class DefaultCredentialsProviderTest { + @BeforeEach + void setUp() { + // Isolate tests and opt out of bound tokens by default to avoid polling delays + AgentIdentityUtils.setEnvReader( + name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "false"; // Triggers isTokenBindingEnabled() = false + } + return null; + }); + } + + @AfterEach + void tearDown() { + // Reset to default behavior. + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String USER_CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; private static final String USER_CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; private static final String GCLOUDSDK_CLIENT_ID = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index e3dcec4b520c..7a22aeaeb9b6 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -34,11 +34,41 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link IdTokenCredentials}. */ class IdTokenCredentialsTest extends BaseSerializationTest { + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + + private TestEnvironmentProvider envProvider; + + @BeforeEach + void setUp() { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test void hashCode_equals() throws IOException { ComputeEngineCredentialsTest.MockMetadataServerTransportFactory transportFactory = diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 524a312ce0c1..8a68dd6eebd5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -65,7 +65,9 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,6 +80,23 @@ */ class LoggingTest { + @BeforeEach + void setUp() { + // Opt out of bound tokens by default to avoid polling delays + AgentIdentityUtils.setEnvReader( + name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "true"; + } + return null; + }); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + private TestAppender setupTestLogger(Class clazz) { TestAppender testAppender = new TestAppender(); testAppender.start(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 1b218b73ef45..e927b0a28f9f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -71,6 +71,7 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; + private String requestMethod; public MockMetadataServerTransport() {} @@ -123,8 +124,13 @@ public MockLowLevelHttpRequest getRequest() { return request; } + public String getRequestMethod() { + return requestMethod; + } + @Override public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { + this.requestMethod = method; if (url.startsWith(ComputeEngineCredentials.getTokenServerEncodedUrl())) { this.request = getMockRequestForTokenEndpoint(url); return this.request; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java index 1a27d3cdc98a..52c23c1f14a1 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java @@ -39,18 +39,50 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.AgentIdentityUtils; import com.google.auth.oauth2.ComputeEngineCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdToken; import com.google.auth.oauth2.IdTokenCredentials; import com.google.auth.oauth2.IdTokenProvider; import com.google.auth.oauth2.OAuth2Utils; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; final class FTComputeEngineCredentialsTest { private final String computeUrl = "https://compute.googleapis.com/compute/v1/projects/gcloud-devel/zones/us-central1-a/instances"; + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + + private TestEnvironmentProvider envProvider; + + @BeforeEach + void setUp() { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + // Opt out by default to avoid polling delays or file reads + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test void RefreshCredentials() throws Exception { final ComputeEngineCredentials credentials = ComputeEngineCredentials.create(); diff --git a/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_cert.pem new file mode 100644 index 000000000000..885ecec9b6cf --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_cert.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDcjCCAlqgAwIBAgIUb5t9VPgT3uTrj+z47BfU8xF6sFowDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjYwNTEx +MjMyODU3WhcNMzYwNTA4MjMyODU3WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJlPtSAh +hYqci8YJaPlgZWUbup4q/TGuaLdR/zWpFCevAQW7/hq29LT8WsoyuirSlbJg67io +PPuNp98e614L/88jP3wMeSuBSXGMyeExrMQ35ZbrnmD268Fl/jEZXPujmeFWmRoy +miKSkNvYLvkN0fiooyFYZdNG0/f6zT+dwJqoOYgeBl9XXDf5QjnQbhQXFQJIbxfQ +TTaGFtEXXwKCs9cd5EvwbiDuaM+F2O7txkaD4r+77AjsXWUFkBBbSUzs2b3ujZot +ZqwNCeeGML/G+5TQj3UExTtDzYpjvncEuqdomFraqXy6DmyELhyChugsz5rGx5Vt +aP3RvDkpLwUxjWcCAwEAAaOBnzCBnDAdBgNVHQ4EFgQUoS1OUx9a1hBHt0tHAefV +7QQ3JYswHwYDVR0jBBgwFoAUoS1OUx9a1hBHt0tHAefV7QQ3JYswDwYDVR0TAQH/ +BAUwAwEB/zBJBgNVHREEQjBAhj5zcGlmZmU6Ly9hZ2VudHMuZ2xvYmFsLnByb2ot +MTIzNDUuc3lzdGVtLmlkLmdvb2cvdGVzdC13b3JrbG9hZDANBgkqhkiG9w0BAQsF +AAOCAQEAfCrNuLFIlpvtDpBKD8lxj2vF4/6fbLhl/5YGGFf2uydaWLr2hTmLFrer +fzGf04hPD5UJTms0ZlvHTvapm1IikxKhkmp8GOlMvKWl/EwIDaJyJ8EaYCpkrTNs +pLR/ujgeSKnIHgm2Oql7HZxB1T25teFcLIIMd0zs69h/Sxejw+OTKnwb7san1qSy +uNvXVDPNFrGyjvBXAVyjyvs3Adz/A+GYhyaP31s+3qGUUR4/axv0M8pUVmK6D8lU +5TkO7smVELt51xaq77Nvv1r5FasJkF5CrnqwAjq1QfVJpDsuTCEriUXguxaXFbzM +Qfw7TEXp3xPVinhkDjlRfiGg6hIvNw== +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_key.pem b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_key.pem new file mode 100644 index 000000000000..b136c31e3db7 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZT7UgIYWKnIvG +CWj5YGVlG7qeKv0xrmi3Uf81qRQnrwEFu/4atvS0/FrKMroq0pWyYOu4qDz7jaff +HuteC//PIz98DHkrgUlxjMnhMazEN+WW655g9uvBZf4xGVz7o5nhVpkaMpoikpDb +2C75DdH4qKMhWGXTRtP3+s0/ncCaqDmIHgZfV1w3+UI50G4UFxUCSG8X0E02hhbR +F18CgrPXHeRL8G4g7mjPhdju7cZGg+K/u+wI7F1lBZAQW0lM7Nm97o2aLWasDQnn +hjC/xvuU0I91BMU7Q82KY753BLqnaJha2ql8ug5shC4cgoboLM+axseVbWj90bw5 +KS8FMY1nAgMBAAECggEAA/g2Eq6bhEYr/lHL2u0kIvT2JSNGBAb/WIPLs/cWE2le +GujOGjrGskSSEaGa0JuiqAg6m9p+hCFPIyFShQOSUsNkYOqUkDJPe8+vG62xsTDx +uMsoAijsKMlI+bqtsPXm+MuWHw4Hww5nDsvI4Sz6iB6bS4E8JB2cEaDEtx/do7aq +5XHJYoy0kO/l87n1UlIq8Pi6yW36pQKKEK+Dm5q+QmgJIeNLFp0hb5r7/fLFENTD +CZO0GvMdmtTkTwyPB1mvFjP6mNNAZcO6j9eaWNCxFhPH+MNWtF+694wgPOQIFKL3 +n4FPmWUS7vGJjNI55Ff2tTHrbWcMuf4RwXbVIG+v1QKBgQDPnNEs2TRrMglFtT6J +wDbICK8AQbBAPFjtZUGxNnsCOdiprZXshZJ7Yq8lokDlsU+5l0YTzRj2v1IcEL49 +oy8WeQfbcOZTGgf+Qo50ZiLhziCqNTlxiXMp3Kg5h4cjxUz2tsF2Nmzn75411uPa +Q/SKHCzfz4uUtHJla8tHENuzdQKBgQC9CwOleHwdS5D1jzBpb/+rvc0vcyYeihZe +v2ao2ItEZZpDyZoou3HiuTe8b9jpg4iS1PtBe6ck/99dBA3CSCF6VCS+++fc+HOz +IyPZjfg8qQ/88XMIEJy8eAa2OFT/EwK3AeZvIuXVumUHI2R8AfkVB0OfjSVZ7hnI +p1ndQ09t6wKBgHDiQVHzX+8RK719SN25Z4/oOM8Y6G5k4a1iqw9iIgwZy9amjagn +EHiKNdVunX7GpCSzPeUyVWqEqG6eI/J7sfS0JjOI9ZMlykbThYWAq2K/oz8o5Wz4 +YWfXlJiDOlWWx7w1rodKHHkX7pwzlXxuCp61pyiiPrDCVJkUvViMsAipAoGAQHda +FfqhcKgNVgAvhTVBXgLKzwyYij+S41qoGppF29w+IDHG1W8epi99d1A5C2DkmRXy +XOFbHX34YNL6Ei/g4sOBCHQFHNDJO+SW3CDS73TD1AFOtghcOtU/jLJnIdkMyvXl +7C5dbGY0/5stMDDIDUi94dITU7ijqE6RkafblWMCgYEAxwFH2JJn8HyU30PVrIFh +AsviZ3BYrCbYFlkZpelCHzHc5imyR9euLwXHIPS0vTE5V8GzkPEgW3DIOFMmBwe2 +8M/jGi/RQCnD7Hh0C+Y0vtUyF8z88lvN5ac6lU9UpGVBBkd8HEsq5ukL3PlnW4B0 +uRGJOa6FU01LIEMoCL5LP28= +-----END PRIVATE KEY----- diff --git a/google-java-format-1.17.0-all-deps.jar b/google-java-format-1.17.0-all-deps.jar new file mode 100644 index 000000000000..15a42a996046 Binary files /dev/null and b/google-java-format-1.17.0-all-deps.jar differ diff --git a/google-java-format-1.22.0-all-deps.jar b/google-java-format-1.22.0-all-deps.jar new file mode 100644 index 000000000000..2e8b83d0824a Binary files /dev/null and b/google-java-format-1.22.0-all-deps.jar differ