diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b91fa381f..005ae5177 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,22 @@ jobs: - run: .kokoro/build.sh env: JOB_TYPE: test + units-logging: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + java: [11, 17, 21] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: ${{matrix.java}} + - run: java -version + - run: .kokoro/build.sh + env: + JOB_TYPE: test-logging units-java8: # Building using Java 17 and run the tests with Java 8 runtime name: "units (8)" diff --git a/.github/workflows/sonar.yaml b/.github/workflows/sonar.yaml index 9d29e5a31..5c5b4da65 100644 --- a/.github/workflows/sonar.yaml +++ b/.github/workflows/sonar.yaml @@ -36,8 +36,10 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + GOOGLE_SDK_JAVA_LOGGING: true run: | mvn -B verify -Dcheckstyle.skip \ + -Djacoco.skip=true -DenableFullTestCoverage \ -Dsonar.coverage.jacoco.xmlReportPaths=oauth2_http/target/site/jacoco/jacoco.xml \ org.sonarsource.scanner.maven:sonar-maven-plugin:sonar \ diff --git a/.kokoro/build.sh b/.kokoro/build.sh index 78daeba8d..ef485eac0 100755 --- a/.kokoro/build.sh +++ b/.kokoro/build.sh @@ -51,6 +51,11 @@ test) mvn test -B -ntp -Dclirr.skip=true -Denforcer.skip=true ${SUREFIRE_JVM_OPT} RETURN_CODE=$? ;; +test-logging) + echo "SUREFIRE_JVM_OPT: ${SUREFIRE_JVM_OPT}" + mvn clean test -P '!slf4j2x,slf4j2x-test' -B -ntp -Dclirr.skip=true -Denforcer.skip=true ${SUREFIRE_JVM_OPT} + RETURN_CODE=$? + ;; lint) mvn com.coveo:fmt-maven-plugin:check -B -ntp RETURN_CODE=$? @@ -66,6 +71,7 @@ integration) -DtrimStackTrace=false \ -Dclirr.skip=true \ -Denforcer.skip=true \ + -Djacoco.skip=true \ -fae \ verify RETURN_CODE=$? @@ -74,14 +80,14 @@ graalvmA) # Run Unit and Integration Tests with Native Image bash .kokoro/populate-secrets.sh export GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/secret_manager/java-it-service-account" - mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Pnative-test test -pl 'oauth2_http' + mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Pnative-test -Pslf4j2x test -pl 'oauth2_http' RETURN_CODE=$? ;; graalvmB) # Run Unit and Integration Tests with Native Image bash .kokoro/populate-secrets.sh export GOOGLE_APPLICATION_CREDENTIALS="${KOKORO_GFILE_DIR}/secret_manager/java-it-service-account" - mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Pnative-test test -pl 'oauth2_http' + mvn -B ${INTEGRATION_TEST_ARGS} -ntp -Pnative -Pnative-test -Pslf4j2x test -pl 'oauth2_http' RETURN_CODE=$? ;; samples) diff --git a/.kokoro/dependencies.sh b/.kokoro/dependencies.sh index bd8960246..775319222 100755 --- a/.kokoro/dependencies.sh +++ b/.kokoro/dependencies.sh @@ -54,6 +54,7 @@ retry_with_backoff 3 10 \ mvn install -B -V -ntp \ -DskipTests=true \ -Dmaven.javadoc.skip=true \ + -Djacoco.skip=true \ -Dclirr.skip=true mvn -B dependency:analyze -DfailOnWarning=true diff --git a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index f7c183099..17aca617a 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -94,6 +94,8 @@ public class ComputeEngineCredentials extends GoogleCredentials static final Duration COMPUTE_REFRESH_MARGIN = Duration.ofMinutes(3).plusSeconds(45); private static final Logger LOGGER = Logger.getLogger(ComputeEngineCredentials.class.getName()); + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(ComputeEngineCredentials.class); static final String DEFAULT_METADATA_SERVER_URL = "http://metadata.google.internal"; @@ -371,11 +373,14 @@ public AccessToken refreshAccessToken() throws IOException { throw new IOException(METADATA_RESPONSE_EMPTY_CONTENT_ERROR_MESSAGE); } GenericData responseData = response.parseAs(GenericData.class); + LoggingUtils.logResponsePayload( + responseData, LOGGER_PROVIDER, "Response payload for access token"); String accessToken = OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX); int expiresInSeconds = OAuth2Utils.validateInt32(responseData, "expires_in", PARSE_ERROR_PREFIX); long expiresAtMilliseconds = clock.currentTimeMillis() + expiresInSeconds * 1000; + return new AccessToken(accessToken, new Date(expiresAtMilliseconds)); } @@ -430,6 +435,7 @@ public IdToken idTokenWithAudience(String targetAudience, List defaultAccount = OAuth2Utils.validateMap(responseData, "default", PARSE_ERROR_ACCOUNT); return OAuth2Utils.validateString(defaultAccount, "email", PARSE_ERROR_ACCOUNT); diff --git a/oauth2_http/java/com/google/auth/oauth2/IamUtils.java b/oauth2_http/java/com/google/auth/oauth2/IamUtils.java index 571d7f668..cd99a8903 100644 --- a/oauth2_http/java/com/google/auth/oauth2/IamUtils.java +++ b/oauth2_http/java/com/google/auth/oauth2/IamUtils.java @@ -72,6 +72,7 @@ class IamUtils { "https://iamcredentials.%s/v1/projects/-/serviceAccounts/%s:signBlob"; private static final String PARSE_ERROR_MESSAGE = "Error parsing error message response. "; private static final String PARSE_ERROR_SIGNATURE = "Error parsing signature response. "; + private static final LoggerProvider LOGGER_PROVIDER = LoggerProvider.forClazz(IamUtils.class); // Following guidance for IAM retries: // https://cloud.google.com/iam/docs/retry-strategy#errors-to-retry @@ -154,7 +155,11 @@ private static String getSignature( IamUtils.IAM_RETRYABLE_STATUS_CODES.contains(response.getStatusCode()))); request.setIOExceptionHandler(new HttpBackOffIOExceptionHandler(backoff)); + LoggingUtils.logRequest( + request, LOGGER_PROVIDER, "Sending request to get signature to sign the blob"); HttpResponse response = request.execute(); + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for signature to sign the blob"); int statusCode = response.getStatusCode(); if (statusCode >= 400 && statusCode < HttpStatusCodes.STATUS_CODE_SERVER_ERROR) { GenericData responseError = response.parseAs(GenericData.class); @@ -181,6 +186,8 @@ private static String getSignature( } GenericData responseData = response.parseAs(GenericData.class); + LoggingUtils.logResponsePayload( + responseData, LOGGER_PROVIDER, "Response payload for sign blob"); return OAuth2Utils.validateString(responseData, "signedBlob", PARSE_ERROR_SIGNATURE); } @@ -234,7 +241,10 @@ static IdToken getIdToken( MetricsUtils.getGoogleCredentialsMetricsHeader( RequestType.ID_TOKEN_REQUEST, credentialTypeForMetrics)); + LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to get ID token"); HttpResponse response = request.execute(); + + LoggingUtils.logResponse(response, LOGGER_PROVIDER, "Received response for ID token request"); int statusCode = response.getStatusCode(); if (statusCode >= 400 && statusCode < HttpStatusCodes.STATUS_CODE_SERVER_ERROR) { GenericData responseError = response.parseAs(GenericData.class); @@ -259,6 +269,8 @@ static IdToken getIdToken( } GenericJson responseData = response.parseAs(GenericJson.class); + LoggingUtils.logResponsePayload( + responseData, LOGGER_PROVIDER, "Response payload for ID token request"); String rawToken = OAuth2Utils.validateString(responseData, "token", PARSE_ERROR_MESSAGE); return IdToken.create(rawToken); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index e5ea4d923..ba32eba29 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -110,6 +110,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private int lifetime; private String iamEndpointOverride; private final String transportFactoryClassName; + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(ImpersonatedCredentials.class); private transient HttpTransportFactory transportFactory; @@ -553,12 +555,17 @@ public AccessToken refreshAccessToken() throws IOException { HttpResponse response = null; try { + LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for refresh access token"); } catch (IOException e) { throw new IOException("Error requesting access token", e); } GenericData responseData = response.parseAs(GenericData.class); + LoggingUtils.logResponsePayload( + responseData, LOGGER_PROVIDER, "Response payload for access token"); response.disconnect(); String accessToken = diff --git a/oauth2_http/java/com/google/auth/oauth2/LoggerProvider.java b/oauth2_http/java/com/google/auth/oauth2/LoggerProvider.java new file mode 100644 index 000000000..c093901db --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/LoggerProvider.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 org.slf4j.Logger; + +class LoggerProvider { + + private Logger logger; + private final Class clazz; + + private LoggerProvider(Class clazz) { + this.clazz = clazz; + } + + static LoggerProvider forClazz(Class clazz) { + return new LoggerProvider(clazz); + } + + Logger getLogger() { + if (logger == null) { + this.logger = Slf4jUtils.getLogger(clazz); + } + return logger; + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java b/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java new file mode 100644 index 000000000..9fa15f96b --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java @@ -0,0 +1,75 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.util.GenericData; + +class LoggingUtils { + + static final String GOOGLE_SDK_JAVA_LOGGING = "GOOGLE_SDK_JAVA_LOGGING"; + private static EnvironmentProvider environmentProvider = + SystemEnvironmentProvider.getInstance(); // this may be reset for testing purpose + + private static boolean loggingEnabled = isLoggingEnabled(); + // expose this setter only for testing purposes + static void setEnvironmentProvider(EnvironmentProvider provider) { + environmentProvider = provider; + // Recalculate LOGGING_ENABLED after setting the new provider + loggingEnabled = isLoggingEnabled(); + } + + static boolean isLoggingEnabled() { + String enableLogging = environmentProvider.getEnv(GOOGLE_SDK_JAVA_LOGGING); + return "true".equalsIgnoreCase(enableLogging); + } + + static void logRequest(HttpRequest request, LoggerProvider loggerProvider, String message) { + if (loggingEnabled) { + Slf4jUtils.logRequest(request, loggerProvider, message); + } + } + + static void logResponse(HttpResponse response, LoggerProvider loggerProvider, String message) { + if (loggingEnabled) { + Slf4jUtils.logResponse(response, loggerProvider, message); + } + } + + static void logResponsePayload( + GenericData genericData, LoggerProvider loggerProvider, String message) { + if (loggingEnabled) { + Slf4jUtils.logResponsePayload(genericData, loggerProvider, message); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index 6e1b59974..0ee363b02 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -96,6 +96,8 @@ public class ServiceAccountCredentials extends GoogleCredentials private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; private static final int TWELVE_HOURS_IN_SECONDS = 43200; private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(ServiceAccountCredentials.class); private final String clientId; private final String clientEmail; @@ -503,6 +505,11 @@ boolean isConfiguredForDomainWideDelegation() { return serviceAccountUser != null && serviceAccountUser.length() > 0; } + private GenericData parseResponseAs(HttpResponse response) throws IOException { + GenericData genericData = response.parseAs(GenericData.class); + LoggingUtils.logResponsePayload(genericData, LOGGER_PROVIDER, "Response payload"); + return genericData; + } /** * Refreshes the OAuth2 access token by getting a new access token using a JSON Web Token (JWT). */ @@ -531,6 +538,7 @@ public AccessToken refreshAccessToken() throws IOException { } request.setParser(new JsonObjectParser(jsonFactory)); + LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); ExponentialBackOff backoff = new ExponentialBackOff.Builder() .setInitialIntervalMillis(OAuth2Utils.INITIAL_RETRY_INTERVAL_MILLIS) @@ -553,6 +561,8 @@ public AccessToken refreshAccessToken() throws IOException { try { response = request.execute(); + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for refresh access token"); } catch (HttpResponseException re) { String message = String.format(errorTemplate, re.getMessage(), getIssuer()); throw GoogleAuthException.createWithTokenEndpointResponseException(re, message); @@ -561,7 +571,7 @@ public AccessToken refreshAccessToken() throws IOException { e, String.format(errorTemplate, e.getMessage(), getIssuer())); } - GenericData responseData = response.parseAs(GenericData.class); + GenericData responseData = parseResponseAs(response); String accessToken = OAuth2Utils.validateString(responseData, "access_token", PARSE_ERROR_PREFIX); int expiresInSeconds = @@ -611,9 +621,13 @@ private IdToken getIdTokenOauthEndpoint(String targetAudience) throws IOExceptio MetricsUtils.getGoogleCredentialsMetricsHeader( RequestType.ID_TOKEN_REQUEST, getMetricsCredentialType())); + LoggingUtils.logRequest( + request, LOGGER_PROVIDER, "Sending request to get ID token via Oauth endpoint"); HttpResponse httpResponse = executeRequest(request); - GenericData responseData = httpResponse.parseAs(GenericData.class); + LoggingUtils.logResponse( + httpResponse, LOGGER_PROVIDER, "Received response for ID token request via Oauth endpoint"); + GenericData responseData = parseResponseAs(httpResponse); String rawToken = OAuth2Utils.validateString(responseData, "id_token", PARSE_ERROR_PREFIX); return IdToken.create(rawToken); } @@ -654,9 +668,14 @@ private IdToken getIdTokenIamEndpoint(String targetAudience) throws IOException HttpRequest request = buildIdTokenRequest(iamIdTokenUri, transportFactory, content); // Use the Access Token from the SSJWT to request the ID Token from IAM Endpoint request.setHeaders(new HttpHeaders().set(AuthHttpConstants.AUTHORIZATION, accessToken)); + + LoggingUtils.logRequest( + request, LOGGER_PROVIDER, "Sending request to get ID token via Iam Endpoint"); HttpResponse httpResponse = executeRequest(request); + LoggingUtils.logResponse( + httpResponse, LOGGER_PROVIDER, "Received response for ID token request via Iam endpoint"); - GenericData responseData = httpResponse.parseAs(GenericData.class); + GenericData responseData = parseResponseAs(httpResponse); // IAM Endpoint returns `token` instead of `id_token` String rawToken = OAuth2Utils.validateString(responseData, "token", PARSE_ERROR_PREFIX); return IdToken.create(rawToken); diff --git a/oauth2_http/java/com/google/auth/oauth2/Slf4jUtils.java b/oauth2_http/java/com/google/auth/oauth2/Slf4jUtils.java new file mode 100644 index 000000000..7e27e75b9 --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/Slf4jUtils.java @@ -0,0 +1,300 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.UrlEncodedContent; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.util.GenericData; +import com.google.gson.Gson; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TreeSet; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.slf4j.spi.LoggingEventBuilder; + +class Slf4jUtils { + + private static final Logger NO_OP_LOGGER = org.slf4j.helpers.NOPLogger.NOP_LOGGER; + private static final Gson gson = new Gson(); + private static final Set SENSITIVE_KEYS = new TreeSet<>(String.CASE_INSENSITIVE_ORDER); + private static boolean hasAddKeyValue; + + static { + hasAddKeyValue = checkIfClazzAvailable("org.slf4j.event.KeyValuePair"); + } + + static { + SENSITIVE_KEYS.addAll( + Arrays.asList( + "token", + "assertion", + "access_token", + "client_secret", + "refresh_token", + "signedBlob", + "authorization")); + } + + static boolean checkIfClazzAvailable(String clazzName) { + try { + Class.forName(clazzName); + return true; // SLF4j 2.x or later + } catch (ClassNotFoundException e) { + return false; // SLF4j 1.x or earlier + } + } + + private Slf4jUtils() {} + + static Logger getLogger(Class clazz) { + return getLogger(clazz, new DefaultLoggerFactoryProvider()); + } + + // constructor with LoggerFactoryProvider to make testing easier + static Logger getLogger(Class clazz, LoggerFactoryProvider factoryProvider) { + if (LoggingUtils.isLoggingEnabled()) { + ILoggerFactory loggerFactory = factoryProvider.getLoggerFactory(); + return loggerFactory.getLogger(clazz.getName()); + } else { + // use SLF4j's NOP logger regardless of bindings + return NO_OP_LOGGER; + } + } + + static void log( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + if (hasAddKeyValue) { + logWithKeyValuePair(logger, level, contextMap, message); + } else { + logWithMDC(logger, level, contextMap, message); + } + } + + // exposed for testing + static void logWithMDC( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + if (!contextMap.isEmpty()) { + for (Entry entry : contextMap.entrySet()) { + String key = entry.getKey(); + Object value = entry.getValue(); + + MDC.put(key, value instanceof String ? (String) value : gson.toJson(value)); + } + } + switch (level) { + case TRACE: + logger.trace(message); + break; + case DEBUG: + logger.debug(message); + break; + case INFO: + logger.info(message); + break; + case WARN: + logger.warn(message); + break; + case ERROR: + logger.error(message); + break; + default: + logger.debug(message); + // Default to DEBUG level + } + if (!contextMap.isEmpty()) { + MDC.clear(); + } + } + + private static void logWithKeyValuePair( + Logger logger, org.slf4j.event.Level level, Map contextMap, String message) { + LoggingEventBuilder loggingEventBuilder; + switch (level) { + case TRACE: + loggingEventBuilder = logger.atTrace(); + break; + case DEBUG: + loggingEventBuilder = logger.atDebug(); + break; + case INFO: + loggingEventBuilder = logger.atInfo(); + break; + case WARN: + loggingEventBuilder = logger.atWarn(); + break; + case ERROR: + loggingEventBuilder = logger.atError(); + break; + default: + loggingEventBuilder = logger.atDebug(); + // Default to DEBUG level + } + contextMap.forEach(loggingEventBuilder::addKeyValue); + loggingEventBuilder.log(message); + } + + static void logRequest(HttpRequest request, LoggerProvider loggerProvider, String message) { + try { + Logger logger = loggerProvider.getLogger(); + if (logger.isInfoEnabled()) { + Map loggingDataMap = new HashMap<>(); + loggingDataMap.put("request.method", request.getRequestMethod()); + loggingDataMap.put("request.url", request.getUrl().toString()); + + Map headers = new HashMap<>(); + request + .getHeaders() + .forEach( + (key, val) -> { + if (SENSITIVE_KEYS.contains(key)) { + String hashedVal = calculateSHA256Hash(String.valueOf(val)); + headers.put(key, hashedVal); + } else { + headers.put(key, val); + } + }); + loggingDataMap.put("request.headers", gson.toJson(headers)); + + if (request.getContent() != null && logger.isDebugEnabled()) { + // are payload always GenericData? If so, can parse and store in json + if (request.getContent() instanceof UrlEncodedContent) { + // this is parsed to GenericData because that is how it is constructed. + GenericData data = (GenericData) ((UrlEncodedContent) request.getContent()).getData(); + Map contextMap = parseGenericData(data); + loggingDataMap.put("request.payload", gson.toJson(contextMap)); + } else if (request.getContent() instanceof JsonHttpContent) { + String jsonData = gson.toJson(((JsonHttpContent) request.getContent()).getData()); + loggingDataMap.put("request.payload", jsonData); + } + + log(logger, org.slf4j.event.Level.DEBUG, loggingDataMap, message); + } else { + + log(logger, org.slf4j.event.Level.INFO, loggingDataMap, message); + } + } + } catch (Exception e) { + // let logging fail silently + } + } + + static void logResponse(HttpResponse response, LoggerProvider loggerProvider, String message) { + try { + Logger logger = loggerProvider.getLogger(); + if (logger.isInfoEnabled()) { + Map responseLogDataMap = new HashMap<>(); + responseLogDataMap.put("response.status", String.valueOf(response.getStatusCode())); + responseLogDataMap.put("response.status.message", response.getStatusMessage()); + + Map headers = new HashMap<>(response.getHeaders()); + responseLogDataMap.put("response.headers", headers.toString()); + log(logger, org.slf4j.event.Level.INFO, responseLogDataMap, message); + } + } catch (Exception e) { + // let logging fail silently + } + } + + static void logResponsePayload( + GenericData genericData, LoggerProvider loggerProvider, String message) { + try { + + Logger logger = loggerProvider.getLogger(); + if (logger.isDebugEnabled()) { + Map contextMap = parseGenericData(genericData); + log(logger, org.slf4j.event.Level.DEBUG, contextMap, message); + } + } catch (Exception e) { + // let logging fail silently + } + } + + private static Map parseGenericData(GenericData genericData) { + Map contextMap = new HashMap<>(); + genericData.forEach( + (key, val) -> { + if (SENSITIVE_KEYS.contains(key)) { + String secretString = String.valueOf(val); + String hashedVal = calculateSHA256Hash(secretString); + contextMap.put(key, hashedVal); + } else { + contextMap.put(key, val.toString()); + } + }); + return contextMap; + } + + private static String calculateSHA256Hash(String data) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] inputBytes = data.getBytes(StandardCharsets.UTF_8); + byte[] hashBytes = digest.digest(inputBytes); + return bytesToHex(hashBytes); + } catch (NoSuchAlgorithmException e) { + return "Error calculating SHA-256 hash."; // do not fail for logging failures + } + } + + private static String bytesToHex(byte[] hash) { + StringBuilder hexString = new StringBuilder(2 * hash.length); + for (byte b : hash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } + + interface LoggerFactoryProvider { + ILoggerFactory getLoggerFactory(); + } + + static class DefaultLoggerFactoryProvider implements LoggerFactoryProvider { + @Override + public ILoggerFactory getLoggerFactory() { + return LoggerFactory.getILoggerFactory(); + } + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java index 453ee5ec8..0aa0fe567 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java @@ -68,6 +68,8 @@ public class UserCredentials extends GoogleCredentials implements IdTokenProvide private static final String GRANT_TYPE = "refresh_token"; private static final String PARSE_ERROR_PREFIX = "Error parsing token refresh response. "; private static final long serialVersionUID = -4800758775038679176L; + private static final LoggerProvider LOGGER_PROVIDER = + LoggerProvider.forClazz(UserCredentials.class); private final String clientId; private final String clientSecret; @@ -283,14 +285,20 @@ private GenericData doRefreshAccessToken() throws IOException { HttpResponse response; try { + LoggingUtils.logRequest(request, LOGGER_PROVIDER, "Sending request to refresh access token"); response = request.execute(); + LoggingUtils.logResponse( + response, LOGGER_PROVIDER, "Received response for refresh access token"); } catch (HttpResponseException re) { throw GoogleAuthException.createWithTokenEndpointResponseException(re); } catch (IOException e) { throw GoogleAuthException.createWithTokenEndpointIOException(e); } - return response.parseAs(GenericData.class); + GenericData data = response.parseAs(GenericData.class); + + LoggingUtils.logResponsePayload(data, LOGGER_PROVIDER, "Response payload for access token"); + return data; } /** diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 2eca3c5be..656585dbd 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -120,8 +120,8 @@ public class ImpersonatedCredentialsTest extends BaseSerializationTest { private static final String PROJECT_ID = "project-id"; public static final String IMPERSONATED_CLIENT_EMAIL = "impersonated-account@iam.gserviceaccount.com"; - private static final List IMMUTABLE_SCOPES_LIST = ImmutableList.of("scope1", "scope2"); - private static final int VALID_LIFETIME = 300; + static final List IMMUTABLE_SCOPES_LIST = ImmutableList.of("scope1", "scope2"); + static final int VALID_LIFETIME = 300; private static final int INVALID_LIFETIME = 43210; private static JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); @@ -164,7 +164,7 @@ public void setup() throws IOException { mockTransportFactory = new MockIAMCredentialsServiceTransportFactory(); } - private GoogleCredentials getSourceCredentials() throws IOException { + static GoogleCredentials getSourceCredentials() throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(SA_PRIVATE_KEY_PKCS8); ServiceAccountCredentials sourceCredentials = diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java new file mode 100644 index 000000000..7a8c87626 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -0,0 +1,527 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 com.google.auth.TestUtils.getDefaultExpireTime; +import static com.google.auth.oauth2.ImpersonatedCredentialsTest.DEFAULT_IMPERSONATION_URL; +import static com.google.auth.oauth2.ImpersonatedCredentialsTest.IMMUTABLE_SCOPES_LIST; +import static com.google.auth.oauth2.ImpersonatedCredentialsTest.IMPERSONATED_CLIENT_EMAIL; +import static com.google.auth.oauth2.ImpersonatedCredentialsTest.TOKEN_WITH_EMAIL; +import static com.google.auth.oauth2.ImpersonatedCredentialsTest.VALID_LIFETIME; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.ACCESS_TOKEN; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.CALL_URI; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.CLIENT_EMAIL; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.DEFAULT_ID_TOKEN; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.SCOPES; +import static com.google.auth.oauth2.ServiceAccountCredentialsTest.createDefaultBuilder; +import static com.google.auth.oauth2.UserCredentialsTest.CLIENT_ID; +import static com.google.auth.oauth2.UserCredentialsTest.CLIENT_SECRET; +import static com.google.auth.oauth2.UserCredentialsTest.REFRESH_TOKEN; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import com.google.api.client.http.HttpStatusCodes; +import com.google.api.client.json.webtoken.JsonWebToken.Payload; +import com.google.api.client.util.ArrayMap; +import com.google.auth.TestUtils; +import com.google.auth.oauth2.ComputeEngineCredentialsTest.MockMetadataServerTransportFactory; +import com.google.gson.Gson; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; + +/** + * This class contains tests for logging events in each credentials workflow Tests are copied from + * credentials test classes with addition of test logging appender setup and test logic for logging. + * This duplicates tests setups, but centralizes logging test setup in this class. + */ +public class LoggingTest { + + private static final Gson gson = new Gson(); + + private TestAppender setupTestLogger(Class clazz) { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + Logger logger = LoggerFactory.getLogger(clazz); + ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + return testAppender; + } + + @BeforeClass + public static void setup() { + // mimic GOOGLE_SDK_JAVA_LOGGING = true + TestEnvironmentProvider testEnvironmentProvider = new TestEnvironmentProvider(); + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + } + + @AfterClass + public static void cleanup() {} + + @Test + public void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() + throws IOException { + TestAppender testAppender = setupTestLogger(UserCredentials.class); + MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); + transportFactory.transport.addClient(CLIENT_ID, CLIENT_SECRET); + transportFactory.transport.addRefreshToken(REFRESH_TOKEN, ACCESS_TOKEN); + UserCredentials userCredentials = + UserCredentials.newBuilder() + .setClientId(CLIENT_ID) + .setClientSecret(CLIENT_SECRET) + .setRefreshToken(REFRESH_TOKEN) + .setHttpTransportFactory(transportFactory) + .build(); + + Map> metadata = userCredentials.getRequestMetadata(CALL_URI); + + TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); + + assertEquals(3, testAppender.events.size()); + assertEquals( + "Sending request to refresh access token", testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for refresh access token", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload for access token", testAppender.events.get(2).getMessage()); + assertEquals(4, testAppender.events.get(2).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(2).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("access_token") + || kvp.key.equals("refresh_token") + || kvp.key.equals("token_type") + || kvp.key.equals("expires_in")); + } + testAppender.stop(); + } + + boolean isValidJson(String jsonString) { + try { + JsonParser.parseString(jsonString); + return true; + } catch (JsonSyntaxException e) { + return false; + } + } + + @Test + public void serviceAccountCredentials_getRequestMetadata_hasAccessToken() throws IOException { + TestAppender testAppender = setupTestLogger(ServiceAccountCredentials.class); + GoogleCredentials credentials = + ServiceAccountCredentialsTest.createDefaultBuilderWithToken(ACCESS_TOKEN) + .setScopes(SCOPES) + .build(); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); + + assertEquals(3, testAppender.events.size()); + + assertEquals( + "Sending request to refresh access token", testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.payload")) { + // ensure correctly formatted as JSON + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for refresh access token", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload", testAppender.events.get(2).getMessage()); + assertEquals(3, testAppender.events.get(2).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(2).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("access_token") + || kvp.key.equals("token_type") + || kvp.key.equals("expires_in")); + } + testAppender.stop(); + } + + @Test + public void serviceAccountCredentials_idTokenWithAudience_iamFlow_targetAudienceMatchesAudClaim() + throws IOException { + TestAppender testAppender = setupTestLogger(ServiceAccountCredentials.class); + String nonGDU = "test.com"; + MockIAMCredentialsServiceTransportFactory transportFactory = + new MockIAMCredentialsServiceTransportFactory(nonGDU); + transportFactory.getTransport().setTargetPrincipal(CLIENT_EMAIL); + transportFactory.getTransport().setIdToken(DEFAULT_ID_TOKEN); + transportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + ServiceAccountCredentials credentials = + createDefaultBuilder() + .setScopes(SCOPES) + .setHttpTransportFactory(transportFactory) + .setUniverseDomain(nonGDU) + .build(); + + String targetAudience = "https://foo.bar"; + IdTokenCredentials tokenCredential = + IdTokenCredentials.newBuilder() + .setIdTokenProvider(credentials) + .setTargetAudience(targetAudience) + .build(); + tokenCredential.refresh(); + assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getAccessToken().getTokenValue()); + assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getIdToken().getTokenValue()); + + // ID Token's aud claim is `https://foo.bar` + assertEquals( + targetAudience, + tokenCredential.getIdToken().getJsonWebSignature().getPayload().getAudience()); + + assertEquals(3, testAppender.events.size()); + + assertEquals( + "Sending request to get ID token via Iam Endpoint", + testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers") || kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for ID token request via Iam endpoint", + testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload", testAppender.events.get(2).getMessage()); + assertEquals(1, testAppender.events.get(2).getKeyValuePairs().size()); + testAppender.stop(); + } + + @Test() + public void impersonatedCredentials_refreshAccessToken_success() + throws IOException, IllegalStateException { + TestAppender testAppender = setupTestLogger(ImpersonatedCredentials.class); + MockIAMCredentialsServiceTransportFactory mockTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); + mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + ImpersonatedCredentialsTest.getSourceCredentials(), + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory); + + assertEquals(ACCESS_TOKEN, targetCredentials.refreshAccessToken().getTokenValue()); + assertEquals( + DEFAULT_IMPERSONATION_URL, mockTransportFactory.getTransport().getRequest().getUrl()); + + assertEquals(3, testAppender.events.size()); + + assertEquals( + "Sending request to refresh access token", testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for refresh access token", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload for access token", testAppender.events.get(2).getMessage()); + assertEquals(2, testAppender.events.get(2).getKeyValuePairs().size()); + + testAppender.stop(); + } + + @Test + public void idTokenWithAudience_withEmail() throws IOException { + TestAppender testAppender = setupTestLogger(IamUtils.class); + MockIAMCredentialsServiceTransportFactory mockTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); + mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + ImpersonatedCredentialsTest.getSourceCredentials(), + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory); + + mockTransportFactory.getTransport().setIdToken(TOKEN_WITH_EMAIL); + + String targetAudience = "https://foo.bar"; + IdTokenCredentials tokenCredential = + IdTokenCredentials.newBuilder() + .setIdTokenProvider(targetCredentials) + .setTargetAudience(targetAudience) + .setOptions(Arrays.asList(IdTokenProvider.Option.INCLUDE_EMAIL)) + .build(); + tokenCredential.refresh(); + assertEquals(TOKEN_WITH_EMAIL, tokenCredential.getAccessToken().getTokenValue()); + Payload p = tokenCredential.getIdToken().getJsonWebSignature().getPayload(); + assertTrue(p.containsKey("email")); + + assertEquals(3, testAppender.events.size()); + + assertEquals("Sending request to get ID token", testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers") || kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals("Received response for ID token request", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload for ID token request", testAppender.events.get(2).getMessage()); + assertEquals(1, testAppender.events.get(2).getKeyValuePairs().size()); + + testAppender.stop(); + } + + @Test + public void sign_sameAs() throws IOException { + TestAppender testAppender = setupTestLogger(IamUtils.class); + MockIAMCredentialsServiceTransportFactory mockTransportFactory = + new MockIAMCredentialsServiceTransportFactory(); + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); + mockTransportFactory.getTransport().setExpireTime(getDefaultExpireTime()); + mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + ImpersonatedCredentialsTest.getSourceCredentials(), + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory); + + byte[] expectedSignature = {0xD, 0xE, 0xA, 0xD}; + + mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.getTransport().setSignedBlob(expectedSignature); + + assertArrayEquals(expectedSignature, targetCredentials.sign(expectedSignature)); + + assertEquals(3, testAppender.events.size()); + + assertEquals( + "Sending request to get signature to sign the blob", + testAppender.events.get(0).getMessage()); + assertEquals(4, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers") || kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for signature to sign the blob", + testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload for sign blob", testAppender.events.get(2).getMessage()); + assertEquals(1, testAppender.events.get(2).getKeyValuePairs().size()); + + testAppender.stop(); + } + + @Test + public void getRequestMetadata_hasAccessToken() throws IOException { + TestAppender testAppender = setupTestLogger(ComputeEngineCredentials.class); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + Map> metadata = credentials.getRequestMetadata(CALL_URI); + + TestUtils.assertContainsBearerToken(metadata, ACCESS_TOKEN); + + assertEquals(3, testAppender.events.size()); + + assertEquals( + "Sending request to refresh access token", testAppender.events.get(0).getMessage()); + assertEquals(3, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals( + "Received response for refresh access token", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + assertEquals("Response payload for access token", testAppender.events.get(2).getMessage()); + assertEquals(3, testAppender.events.get(2).getKeyValuePairs().size()); + + testAppender.stop(); + } + + @Test + @SuppressWarnings("unchecked") + public void idTokenWithAudience_full() throws IOException { + TestAppender testAppender = setupTestLogger(ComputeEngineCredentials.class); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + String targetAudience = "https://foo.bar"; + IdTokenCredentials tokenCredential = + IdTokenCredentials.newBuilder() + .setIdTokenProvider(credentials) + .setTargetAudience(targetAudience) + .setOptions(Arrays.asList(IdTokenProvider.Option.FORMAT_FULL)) + .build(); + tokenCredential.refresh(); + Payload p = tokenCredential.getIdToken().getJsonWebSignature().getPayload(); + assertTrue("Full ID Token format not provided", p.containsKey("google")); + ArrayMap googleClaim = (ArrayMap) p.get("google"); + assertTrue(googleClaim.containsKey("compute_engine")); + + assertEquals(2, testAppender.events.size()); + + assertEquals("Sending request to get ID token", testAppender.events.get(0).getMessage()); + assertEquals(3, testAppender.events.get(0).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + assertEquals("Received response for ID token request", testAppender.events.get(1).getMessage()); + assertEquals(3, testAppender.events.get(1).getKeyValuePairs().size()); + for (KeyValuePair kvp : testAppender.events.get(1).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("response.headers") + || kvp.key.equals("response.status") + || kvp.key.equals("response.status.message")); + } + + testAppender.stop(); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingUtilsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingUtilsTest.java new file mode 100644 index 000000000..abad61f32 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingUtilsTest.java @@ -0,0 +1,67 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Before; +import org.junit.Test; + +public class LoggingUtilsTest { + + private TestEnvironmentProvider testEnvironmentProvider; + + @Before + public void setup() { + testEnvironmentProvider = new TestEnvironmentProvider(); + } + + @Test + public void testIsLoggingEnabled_true() { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + assertTrue(LoggingUtils.isLoggingEnabled()); + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "TRUE"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + assertTrue(LoggingUtils.isLoggingEnabled()); + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "True"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + assertTrue(LoggingUtils.isLoggingEnabled()); + } + + @Test + public void testIsLoggingEnabled_defaultToFalse() { + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + assertFalse(LoggingUtils.isLoggingEnabled()); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java index e32a74292..0fa1f6672 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ServiceAccountCredentialsTest.java @@ -87,7 +87,7 @@ @RunWith(JUnit4.class) public class ServiceAccountCredentialsTest extends BaseSerializationTest { - private static final String CLIENT_EMAIL = + static final String CLIENT_EMAIL = "36680232662-vrd7ji19qe3nelgchd0ah2csanun6bnr@developer.gserviceaccount.com"; private static final String CLIENT_ID = "36680232662-vrd7ji19qe3nelgchd0ah2csanun6bnr.apps.googleusercontent.com"; @@ -105,14 +105,14 @@ public class ServiceAccountCredentialsTest extends BaseSerializationTest { + "gidhycxS86dxpEljnOMCw8CKoUBd5I880IUahEiUltk7OLJYS/Ts1wbn3kPOVX3wyJs8WBDtBkFrDHW2ezth2QJ" + "ADj3e1YhMVdjJW5jqwlD/VNddGjgzyunmiZg0uOXsHXbytYmsA545S8KRQFaJKFXYYFo2kOjqOiC1T2cAzMDjCQ" + "==\n-----END PRIVATE KEY-----\n"; - private static final String ACCESS_TOKEN = "1/MkSJoj1xsli0AccessToken_NKPY2"; - private static final Collection SCOPES = Collections.singletonList("dummy.scope"); + static final String ACCESS_TOKEN = "1/MkSJoj1xsli0AccessToken_NKPY2"; + static final Collection SCOPES = Collections.singletonList("dummy.scope"); private static final Collection DEFAULT_SCOPES = Collections.singletonList("dummy.default.scope"); private static final String USER = "user@example.com"; private static final String PROJECT_ID = "project-id"; private static final Collection EMPTY_SCOPES = Collections.emptyList(); - private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); + static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private static final String JWT_AUDIENCE = "http://googleapis.com/"; private static final HttpTransportFactory DUMMY_TRANSPORT_FACTORY = new MockTokenServerTransportFactory(); @@ -127,19 +127,20 @@ public class ServiceAccountCredentialsTest extends BaseSerializationTest { private static final int INVALID_LIFETIME = 43210; private static final String JWT_ACCESS_PREFIX = "Bearer "; - private ServiceAccountCredentials.Builder createDefaultBuilderWithToken(String accessToken) + static ServiceAccountCredentials.Builder createDefaultBuilderWithToken(String accessToken) throws IOException { MockTokenServerTransportFactory transportFactory = new MockTokenServerTransportFactory(); transportFactory.transport.addServiceAccount(CLIENT_EMAIL, accessToken); return createDefaultBuilder().setHttpTransportFactory(transportFactory); } - private ServiceAccountCredentials.Builder createDefaultBuilderWithScopes( + private static ServiceAccountCredentials.Builder createDefaultBuilderWithScopes( Collection scopes) throws IOException { return createDefaultBuilder().setScopes(scopes); } - private ServiceAccountCredentials.Builder createDefaultBuilderWithKey(PrivateKey privateKey) { + private static ServiceAccountCredentials.Builder createDefaultBuilderWithKey( + PrivateKey privateKey) { ServiceAccountCredentials.Builder builder = ServiceAccountCredentials.newBuilder() .setClientId(CLIENT_ID) @@ -153,7 +154,7 @@ private ServiceAccountCredentials.Builder createDefaultBuilderWithKey(PrivateKey return builder; } - private ServiceAccountCredentials.Builder createDefaultBuilder() throws IOException { + static ServiceAccountCredentials.Builder createDefaultBuilder() throws IOException { PrivateKey privateKey = OAuth2Utils.privateKeyFromPkcs8(PRIVATE_KEY_PKCS8); return createDefaultBuilderWithKey(privateKey); } @@ -884,7 +885,7 @@ public void idTokenWithAudience_oauthFlow_targetAudienceMatchesAudClaim() throws targetAudience, tokenCredential.getIdToken().getJsonWebSignature().getPayload().getAudience()); - // verify id token request metrics headers + // verify ID token request metrics headers Map> idTokenRequestHeader = transportFactory.transport.getRequest().getHeaders(); com.google.auth.oauth2.TestUtils.validateMetricsHeader(idTokenRequestHeader, "it", "sa"); diff --git a/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtils1xTest.java b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtils1xTest.java new file mode 100644 index 000000000..590c6d162 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtils1xTest.java @@ -0,0 +1,81 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertNotNull; +import static org.mockito.Mockito.when; + +import com.google.api.client.util.GenericData; +import java.util.Map; +import org.junit.Ignore; +import org.junit.Test; +import org.mockito.Mockito; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Slf4jUtils1xTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(Slf4jUtilsTest.class); + + private TestAppender setupTestLogger() { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + ((ch.qos.logback.classic.Logger) LOGGER).addAppender(testAppender); + return testAppender; + } + + @Test + @Ignore("This test needs slf4j1.x") + public void testLogGenericData() { + TestAppender testAppender = setupTestLogger(); + GenericData genericData = Mockito.mock(GenericData.class); + + GenericData data = new GenericData(); + data.put("key1", "value1"); + data.put("token", "value2"); + + LoggerProvider loggerProvider = Mockito.mock(LoggerProvider.class); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logResponsePayload(data, loggerProvider, "test generic data"); + + assertEquals(1, testAppender.events.size()); + Map mdcPropertyMap = testAppender.events.get(0).getMDCPropertyMap(); + assertEquals(2, mdcPropertyMap.size()); + assertEquals("value1", mdcPropertyMap.get("key1")); + assertNotNull(mdcPropertyMap.get("token")); + assertNotEquals("value2", mdcPropertyMap.get("token")); + + testAppender.stop(); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java new file mode 100644 index 000000000..e393a445f --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsLogbackTest.java @@ -0,0 +1,218 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import ch.qos.logback.classic.spi.ILoggingEvent; +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.UrlEncodedContent; +import com.google.api.client.util.GenericData; +import com.google.gson.JsonParser; +import com.google.gson.JsonSyntaxException; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.event.KeyValuePair; +import org.slf4j.event.Level; + +// part of Slf4jUtils test that needs logback dependency +public class Slf4jUtilsLogbackTest { + + private static final Logger LOGGER = LoggerFactory.getLogger(Slf4jUtilsLogbackTest.class); + + private TestEnvironmentProvider testEnvironmentProvider; + + @Before + public void setup() { + testEnvironmentProvider = new TestEnvironmentProvider(); + } + + @Test + public void testLogWithMDC_slf4jLogger() { + + TestAppender testAppender = setupTestLogger(); + + Map contextMap = new HashMap<>(); + contextMap.put("key1", "value1"); + contextMap.put("key2", "value2"); + Slf4jUtils.logWithMDC(LOGGER, Level.DEBUG, contextMap, "test message"); + + assertEquals(1, testAppender.events.size()); + assertEquals("test message", testAppender.events.get(0).getMessage()); + + // Verify MDC content + ILoggingEvent event = testAppender.events.get(0); + assertEquals(2, event.getMDCPropertyMap().size()); + assertEquals(ch.qos.logback.classic.Level.DEBUG, event.getLevel()); + assertEquals("value1", event.getMDCPropertyMap().get("key1")); + assertEquals("value2", event.getMDCPropertyMap().get("key2")); + + testAppender.stop(); + } + + @Test + public void testLogWithMDC_INFO() { + TestAppender testAppender = setupTestLogger(); + Slf4jUtils.logWithMDC(LOGGER, Level.INFO, new HashMap<>(), "test message"); + + assertEquals(1, testAppender.events.size()); + assertEquals(ch.qos.logback.classic.Level.INFO, testAppender.events.get(0).getLevel()); + testAppender.stop(); + } + + @Test + public void testLogWithMDC_TRACE_notEnabled() { + TestAppender testAppender = setupTestLogger(); + Slf4jUtils.logWithMDC(LOGGER, Level.TRACE, new HashMap<>(), "test message"); + + assertEquals(0, testAppender.events.size()); + testAppender.stop(); + } + + @Test + public void testLogWithMDC_WARN() { + TestAppender testAppender = setupTestLogger(); + Slf4jUtils.logWithMDC(LOGGER, Level.WARN, new HashMap<>(), "test message"); + + assertEquals(1, testAppender.events.size()); + assertEquals(ch.qos.logback.classic.Level.WARN, testAppender.events.get(0).getLevel()); + testAppender.stop(); + } + + @Test + public void testLogWithMDC_ERROR() { + TestAppender testAppender = setupTestLogger(); + Slf4jUtils.logWithMDC(LOGGER, Level.ERROR, new HashMap<>(), "test message"); + + assertEquals(1, testAppender.events.size()); + assertEquals(ch.qos.logback.classic.Level.ERROR, testAppender.events.get(0).getLevel()); + testAppender.stop(); + } + + @Test + public void testLogGenericData() { + // mimic GOOGLE_SDK_JAVA_LOGGING = true + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + GenericData genericData = mock(GenericData.class); + + GenericData data = new GenericData(); + data.put("key1", "value1"); + data.put("token", "value2"); + + LoggerProvider loggerProvider = mock(LoggerProvider.class); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logResponsePayload(data, loggerProvider, "test generic data"); + + assertEquals(1, testAppender.events.size()); + List keyValuePairs = testAppender.events.get(0).getKeyValuePairs(); + assertEquals(2, keyValuePairs.size()); + for (KeyValuePair kvp : keyValuePairs) { + + assertTrue( + "Key should be either 'key1' or 'token'", + kvp.key.equals("key1") || kvp.key.equals("token")); + } + + testAppender.stop(); + } + + @Test + public void testLogRequest() throws IOException { + // mimic GOOGLE_SDK_JAVA_LOGGING = true + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + + TestAppender testAppender = setupTestLogger(); + GenericData genericData = mock(GenericData.class); + + GenericData tokenRequest = new GenericData(); + tokenRequest.set("client_id", "clientId"); + tokenRequest.set("client_secret", "clientSecret"); + UrlEncodedContent content = new UrlEncodedContent(tokenRequest); + + MockHttpTransportFactory mockHttpTransportFactory = new MockHttpTransportFactory(); + + HttpRequestFactory requestFactory = mockHttpTransportFactory.create().createRequestFactory(); + HttpRequest request = + requestFactory.buildPostRequest(new GenericUrl(OAuth2Utils.TOKEN_SERVER_URI), content); + + LoggerProvider loggerProvider = mock(LoggerProvider.class); + when(loggerProvider.getLogger()).thenReturn(LOGGER); + LoggingUtils.logRequest(request, loggerProvider, "test log request"); + + assertEquals(1, testAppender.events.size()); + assertEquals("test log request", testAppender.events.get(0).getMessage()); + List keyValuePairs = testAppender.events.get(0).getKeyValuePairs(); + assertEquals(4, keyValuePairs.size()); + for (KeyValuePair kvp : testAppender.events.get(0).getKeyValuePairs()) { + assertTrue( + kvp.key.equals("request.headers") + || kvp.key.equals("request.payload") + || kvp.key.equals("request.method") + || kvp.key.equals("request.url")); + if (kvp.key.equals("request.headers") || kvp.key.equals("request.payload")) { + assertTrue(isValidJson((String) kvp.value)); + } + } + testAppender.stop(); + } + + boolean isValidJson(String jsonString) { + try { + JsonParser.parseString(jsonString); + return true; + } catch (JsonSyntaxException e) { + return false; + } + } + + private TestAppender setupTestLogger() { + TestAppender testAppender = new TestAppender(); + testAppender.start(); + ((ch.qos.logback.classic.Logger) LOGGER).addAppender(testAppender); + return testAppender; + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsTest.java new file mode 100644 index 000000000..222b1fded --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/Slf4jUtilsTest.java @@ -0,0 +1,101 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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.Assert.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.google.auth.oauth2.Slf4jUtils.LoggerFactoryProvider; +import org.junit.Before; +import org.junit.Test; +import org.slf4j.ILoggerFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.helpers.NOPLogger; + +public class Slf4jUtilsTest { + private static final Logger LOGGER = LoggerFactory.getLogger(Slf4jUtilsTest.class); + + private TestEnvironmentProvider testEnvironmentProvider; + + @Before + public void setup() { + testEnvironmentProvider = new TestEnvironmentProvider(); + } + // This test mimics GOOGLE_SDK_JAVA_LOGGING != true + @Test + public void testGetLogger_loggingDisabled_shouldGetNOPLogger() { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "false"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + Logger logger = Slf4jUtils.getLogger(Slf4jUtilsTest.class); + + assertEquals(NOPLogger.class, logger.getClass()); + assertFalse(logger.isInfoEnabled()); + assertFalse(logger.isDebugEnabled()); + } + + // This test require binding (e.g. logback) be present + @Test + public void testGetLogger_loggingEnabled_slf4jBindingPresent() { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + Logger logger = Slf4jUtils.getLogger(LoggingUtilsTest.class); + assertNotNull(logger); + assertNotEquals(NOPLogger.class, logger.getClass()); + } + + @Test + public void testGetLogger_loggingEnabled_noBinding() { + testEnvironmentProvider.setEnv(LoggingUtils.GOOGLE_SDK_JAVA_LOGGING, "true"); + LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); + // Create a mock LoggerFactoryProvider + LoggerFactoryProvider mockLoggerFactoryProvider = mock(LoggerFactoryProvider.class); + ILoggerFactory mockLoggerFactory = mock(ILoggerFactory.class); + when(mockLoggerFactoryProvider.getLoggerFactory()).thenReturn(mockLoggerFactory); + when(mockLoggerFactory.getLogger(anyString())) + .thenReturn(org.slf4j.helpers.NOPLogger.NOP_LOGGER); + + // Use the mock LoggerFactoryProvider in getLogger() + Logger logger = Slf4jUtils.getLogger(LoggingUtilsTest.class, mockLoggerFactoryProvider); + + // Assert that the returned logger is a NOPLogger + assertTrue(logger instanceof org.slf4j.helpers.NOPLogger); + } + + @Test + public void testCheckIfClazzAvailable() { + assertFalse(Slf4jUtils.checkIfClazzAvailable("fake.class.should.not.be.in.classpath")); + assertTrue(Slf4jUtils.checkIfClazzAvailable("org.slf4j.event.KeyValuePair")); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/TestAppender.java b/oauth2_http/javatests/com/google/auth/oauth2/TestAppender.java new file mode 100644 index 000000000..66fd046fd --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/TestAppender.java @@ -0,0 +1,53 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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 ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import java.util.ArrayList; +import java.util.List; + +/** Logback appender used to set up tests. */ +public class TestAppender extends AppenderBase { + public List events = new ArrayList<>(); + + @Override + protected void append(ILoggingEvent eventObject) { + // triggering Logback to capture the current MDC context and store it with the log event + eventObject.getMDCPropertyMap(); + events.add(eventObject); + } + + public void clearEvents() { + events.clear(); + } +} diff --git a/oauth2_http/javatests/com/google/auth/oauth2/UserCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/UserCredentialsTest.java index 254e6f550..d50190bc3 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/UserCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/UserCredentialsTest.java @@ -71,9 +71,9 @@ @RunWith(JUnit4.class) public class UserCredentialsTest extends BaseSerializationTest { - private static final String CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; - private static final String CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; - private static final String REFRESH_TOKEN = "1/Tl6awhpFjkMkSJoj1xsli0H2eL5YsMgU_NKPY2TyGWY"; + static final String CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; + static final String CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; + static final String REFRESH_TOKEN = "1/Tl6awhpFjkMkSJoj1xsli0H2eL5YsMgU_NKPY2TyGWY"; private static final String ACCESS_TOKEN = "1/MkSJoj1xsli0AccessToken_NKPY2"; private static final String QUOTA_PROJECT = "sample-quota-project-id"; private static final Collection SCOPES = Collections.singletonList("dummy.scope"); @@ -767,7 +767,7 @@ public void IdTokenCredentials_WithUserEmailScope_success() throws IOException { assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getAccessToken().getTokenValue()); assertEquals(DEFAULT_ID_TOKEN, tokenCredential.getIdToken().getTokenValue()); - // verify id token request metrics headers, same as access token request + // verify ID token request metrics headers, same as access token request Map> idTokenRequestHeader = transportFactory.transport.getRequest().getHeaders(); com.google.auth.oauth2.TestUtils.validateMetricsHeader(idTokenRequestHeader, "untracked", "u"); diff --git a/oauth2_http/pom.xml b/oauth2_http/pom.xml index 5903eeaf2..1607f080a 100644 --- a/oauth2_http/pom.xml +++ b/oauth2_http/pom.xml @@ -68,8 +68,114 @@ + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + UTF-8 + + + **/Slf4jUtilsLogbackTest.java + **/Slf4jUtils1xTest.java + **/Slf4jUtilsTest.java + **/TestAppender.java + **/LoggingTest.java + + + + + + + + slf4j2x + + true + + + + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + UTF-8 + + + + **/Slf4jUtilsLogbackTest.java + **/Slf4jUtils1xTest.java + **/Slf4jUtilsTest.java + **/TestAppender.java + **/LoggingTest.java + + + + + + + + org.slf4j + slf4j-api + ${project.slf4j.version} + true + + + com.google.code.gson + gson + ${project.gson.version} + + + + + slf4j2x-test + + + + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + UTF-8 + + + **/Slf4jUtilsLogbackTest.java + **/Slf4jUtils1xTest.java + **/Slf4jUtilsTest.java + **/TestAppender.java + **/LoggingTest.java + + + + + + org.slf4j + slf4j-api + ${project.slf4j.version} + + + + ch.qos.logback + logback-classic + 1.5.16 + test + + + ch.qos.logback + logback-core + 1.5.16 + test + + diff --git a/pom.xml b/pom.xml index a8124b74a..1453750d7 100644 --- a/pom.xml +++ b/pom.xml @@ -84,6 +84,8 @@ 3.25.5 0.9.0-proto3 1.15.0 + 2.0.16 + 2.11.0 @@ -419,6 +421,31 @@ + + slf4j2x-test + + + + + maven-compiler-plugin + 3.13.0 + + 1.8 + 1.8 + UTF-8 + + + **/Slf4jUtilsLogbackTest.java + **/Slf4jUtils1xTest.java + **/Slf4jUtilsTest.java + **/TestAppender.java + **/LoggingTest.java + + + + + + release-sign-artifacts