From fcd4c3ce5fae006e6f7830f2a2361425dac657d4 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Fri, 20 Mar 2026 17:50:47 -0400 Subject: [PATCH 1/4] chore: Add additional warnings for sensitive tokens --- .../com/google/auth/oauth2/AccessToken.java | 7 ++ ...ernalAccountAuthorizedUserCredentials.java | 7 ++ .../auth/oauth2/ImpersonatedCredentials.java | 7 ++ .../com/google/auth/oauth2/LoggingUtils.java | 3 +- .../google/auth/oauth2/OAuth2Credentials.java | 8 ++ .../auth/oauth2/Slf4jLoggingHelpers.java | 18 ++++- .../google/auth/oauth2/UserCredentials.java | 7 ++ .../com/google/auth/oauth2/LoggingTest.java | 81 ++++++++++++++++++- 8 files changed, 133 insertions(+), 5 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/AccessToken.java b/oauth2_http/java/com/google/auth/oauth2/AccessToken.java index 40032ca47..4ea49d93a 100644 --- a/oauth2_http/java/com/google/auth/oauth2/AccessToken.java +++ b/oauth2_http/java/com/google/auth/oauth2/AccessToken.java @@ -114,6 +114,13 @@ public int hashCode() { return Objects.hash(tokenValue, expirationTimeMillis, scopes); } + /** + * Returns a string representation of this access token, including the raw token value. + * + *

Security Warning: The output of this method includes the raw, unmasked access token + * value. Do not log this output in production environments as it may expose sensitive + * credentials. + */ @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java index 90d62a562..b274fec76 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -284,6 +284,13 @@ public int hashCode() { quotaProjectId); } + /** + * Returns a string representation of this credential. + * + *

Security Warning: The output of this method includes sensitive fields such as the + * client secret, refresh token, and request metadata containing the raw Bearer access token. Do + * not log this output in production environments as it may expose sensitive credentials. + */ @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 27d038784..22725a2b4 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -710,6 +710,13 @@ public int hashCode() { iamEndpointOverride); } + /** + * Returns a string representation of this credential. + * + *

Security Warning: The output of this method includes the source credentials which may + * recursively contain sensitive fields such as access tokens. Do not log this output in production + * environments as it may expose sensitive credentials. + */ @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java b/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java index f35856398..b08c56242 100644 --- a/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java +++ b/oauth2_http/java/com/google/auth/oauth2/LoggingUtils.java @@ -79,7 +79,8 @@ static void logResponsePayload( /** * Generic log method to use when not logging standard request, response and payload. * - *

Note: This does not mask the data. Log carefully if the data contains sensitive tokens. + *

Any key in the provided {@code contextMap} that matches the sensitive keys set (e.g. + * access_token, refresh_token) will have its value masked via SHA-256 hash before being logged. */ static void log( LoggerProvider loggerProvider, Level level, Map contextMap, String message) { diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index f86e3c8d7..b4a933963 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -446,6 +446,14 @@ protected Map> getRequestMetadataInternal() { return null; } + /** + * Returns a string representation of this credential, including request metadata and access + * token. + * + *

Security Warning: The output of this method includes the request metadata which + * contains the raw Bearer access token, and the raw access token value. Do not log this output in + * production environments as it may expose sensitive credentials. + */ @Override public String toString() { OAuthValue localValue = value; diff --git a/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java b/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java index 880cbb43e..b10328661 100644 --- a/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java +++ b/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java @@ -115,8 +115,8 @@ static void logResponse(HttpResponse response, LoggerProvider loggerProvider, St 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()); + Map headers = parseGenericData(response.getHeaders()); + responseLogDataMap.put("response.headers", gson.toJson(headers)); Slf4jUtils.log(logger, org.slf4j.event.Level.INFO, responseLogDataMap, message); } } catch (Exception e) { @@ -138,12 +138,24 @@ static void logResponsePayload( } } + /** + * Generic log method for non-standard request/response/payload logging. + * + *

Any key in the provided {@code contextMap} that matches the {@code SENSITIVE_KEYS} set will + * have its value masked via SHA-256 hash before being logged. + * + * @param loggerProvider the logger provider for the calling class + * @param level the java.util.logging level to map to SLF4J + * @param contextMap the key-value pairs to log + * @param message the log message + */ static void log( LoggerProvider loggerProvider, Level level, Map contextMap, String message) { try { Logger logger = loggerProvider.getLogger(); org.slf4j.event.Level slf4jLevel = matchUtilLevelToSLF4JLevel(level); - Slf4jUtils.log(logger, slf4jLevel, contextMap, message); + Map maskedContextMap = parseGenericData(contextMap); + Slf4jUtils.log(logger, slf4jLevel, maskedContextMap, message); } catch (Exception e) { // let logging fail silently } diff --git a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java index 8f9174390..3670ac7a6 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java @@ -361,6 +361,13 @@ public int hashCode() { quotaProjectId); } + /** + * Returns a string representation of this credential. + * + *

Security Warning: The output of this method includes sensitive fields such as the + * refresh token and request metadata containing the raw Bearer access token. Do not log this + * output in production environments as it may expose sensitive credentials. + */ @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 44c9ab3bc..8af790e9e 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -650,7 +650,23 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() assertEquals(3, testAppender.events.size()); - // Verify response payload has tokens masked + // 1. Verify request log contains properly formatted payload (JsonHttpContent masking) + ILoggingEvent requestLog = testAppender.events.get(0); + assertEquals( + "Sending request to refresh access token", requestLog.getMessage()); + String requestPayload = null; + for (KeyValuePair kvp : requestLog.getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + requestPayload = (String) kvp.value; + } + } + // When logged at DEBUG level, the request payload should be present and valid JSON + // (the JsonHttpContent payload goes through parseGenericData for masking) + if (requestPayload != null) { + assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); + } + + // 2. Verify response payload has tokens masked assertEquals("Response payload for access token", testAppender.events.get(2).getMessage()); boolean foundAccessToken = false; for (KeyValuePair kvp : testAppender.events.get(2).getKeyValuePairs()) { @@ -669,4 +685,67 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() assertTrue(foundAccessToken, "Expected accessToken in response payload logs"); testAppender.stop(); } + + @Test + void impersonatedCredentials_requestPayload_masksJsonHttpContentSensitiveKeys() + throws IOException, IllegalStateException { + // Set DEBUG level to ensure request payloads are logged + Logger logger = LoggerFactory.getLogger(ImpersonatedCredentials.class); + ch.qos.logback.classic.Logger logbackLogger = (ch.qos.logback.classic.Logger) logger; + ch.qos.logback.classic.Level previousLevel = logbackLogger.getLevel(); + logbackLogger.setLevel(ch.qos.logback.classic.Level.DEBUG); + + TestAppender testAppender = new TestAppender(); + testAppender.start(); + logbackLogger.addAppender(testAppender); + + try { + 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); + + targetCredentials.refreshAccessToken(); + + // Find the request log event + ILoggingEvent requestLog = testAppender.events.get(0); + assertEquals("Sending request to refresh access token", requestLog.getMessage()); + + // Extract request.payload + String requestPayload = null; + for (KeyValuePair kvp : requestLog.getKeyValuePairs()) { + if ("request.payload".equals(kvp.key)) { + requestPayload = (String) kvp.value; + } + } + + // At DEBUG level, request payload must be present + assertNotNull(requestPayload, "Request payload should be logged at DEBUG level"); + assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); + + // The ImpersonatedCredentials request payload uses JsonHttpContent with fields: + // delegates, scope, lifetime. None of these are in SENSITIVE_KEYS, so they should + // appear as-is (not hashed). This validates that JsonHttpContent goes through + // parseGenericData without breaking. + assertFalse( + requestPayload.contains("\"delegates\":null"), + "Payload should be properly serialized from JsonHttpContent"); + } finally { + logbackLogger.setLevel(previousLevel); + testAppender.stop(); + } + } } + From b772034c23ae24b12dab90cde0677167de138cbe Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Mon, 23 Mar 2026 13:32:01 -0400 Subject: [PATCH 2/4] chore: Fix lint issues --- .../java/com/google/auth/oauth2/ImpersonatedCredentials.java | 4 ++-- oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 22725a2b4..4f256fe52 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -714,8 +714,8 @@ public int hashCode() { * Returns a string representation of this credential. * *

Security Warning: The output of this method includes the source credentials which may - * recursively contain sensitive fields such as access tokens. Do not log this output in production - * environments as it may expose sensitive credentials. + * recursively contain sensitive fields such as access tokens. Do not log this output in + * production environments as it may expose sensitive credentials. */ @Override public String toString() { diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 8af790e9e..ecdcadbc6 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -652,8 +652,7 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() // 1. Verify request log contains properly formatted payload (JsonHttpContent masking) ILoggingEvent requestLog = testAppender.events.get(0); - assertEquals( - "Sending request to refresh access token", requestLog.getMessage()); + assertEquals("Sending request to refresh access token", requestLog.getMessage()); String requestPayload = null; for (KeyValuePair kvp : requestLog.getKeyValuePairs()) { if ("request.payload".equals(kvp.key)) { @@ -748,4 +747,3 @@ void impersonatedCredentials_requestPayload_masksJsonHttpContentSensitiveKeys() } } } - From f29b6e9fd374b5ea2bc70894fa1600091da02006 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Mon, 23 Mar 2026 14:45:15 -0400 Subject: [PATCH 3/4] chore: Document the test rationale --- .../com/google/auth/oauth2/LoggingTest.java | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index ecdcadbc6..524a312ce 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -627,6 +627,8 @@ void stsRequestHandler_exchangeToken_masksSensitiveTokens() throws IOException { testAppender.stop(); } + // We specifically test ImpersonatedCredentials here because it constructs its HTTP requests + // using JsonHttpContent, unlike most other credentials which use UrlEncodedContent. @Test void impersonatedCredentials_exchangeToken_masksSensitiveTokens() throws IOException, IllegalStateException { @@ -659,8 +661,7 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() requestPayload = (String) kvp.value; } } - // When logged at DEBUG level, the request payload should be present and valid JSON - // (the JsonHttpContent payload goes through parseGenericData for masking) + // When logged at DEBUG level, the request payload should be present and valid JSON. if (requestPayload != null) { assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); } @@ -685,6 +686,8 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() testAppender.stop(); } + // We specifically use ImpersonatedCredentials for this test because its request payload + // is formatted using JsonHttpContent, whereas other credentials primarily use UrlEncodedContent. @Test void impersonatedCredentials_requestPayload_masksJsonHttpContentSensitiveKeys() throws IOException, IllegalStateException { @@ -734,10 +737,8 @@ void impersonatedCredentials_requestPayload_masksJsonHttpContentSensitiveKeys() assertNotNull(requestPayload, "Request payload should be logged at DEBUG level"); assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); - // The ImpersonatedCredentials request payload uses JsonHttpContent with fields: - // delegates, scope, lifetime. None of these are in SENSITIVE_KEYS, so they should - // appear as-is (not hashed). This validates that JsonHttpContent goes through - // parseGenericData without breaking. + // The request payload uses JsonHttpContent with fields: delegates, scope, lifetime. None of + // these are in SENSITIVE_KEYS, so they should appear as-is (not hashed). assertFalse( requestPayload.contains("\"delegates\":null"), "Payload should be properly serialized from JsonHttpContent"); From 48a719ef694987057be3fa692a824768fd966afb Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Mon, 23 Mar 2026 15:26:01 -0400 Subject: [PATCH 4/4] chore: Move these file changes to a new PR --- .../auth/oauth2/Slf4jLoggingHelpers.java | 18 +---- .../com/google/auth/oauth2/LoggingTest.java | 80 +------------------ 2 files changed, 4 insertions(+), 94 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java b/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java index b10328661..880cbb43e 100644 --- a/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java +++ b/oauth2_http/java/com/google/auth/oauth2/Slf4jLoggingHelpers.java @@ -115,8 +115,8 @@ static void logResponse(HttpResponse response, LoggerProvider loggerProvider, St responseLogDataMap.put("response.status", String.valueOf(response.getStatusCode())); responseLogDataMap.put("response.status.message", response.getStatusMessage()); - Map headers = parseGenericData(response.getHeaders()); - responseLogDataMap.put("response.headers", gson.toJson(headers)); + Map headers = new HashMap<>(response.getHeaders()); + responseLogDataMap.put("response.headers", headers.toString()); Slf4jUtils.log(logger, org.slf4j.event.Level.INFO, responseLogDataMap, message); } } catch (Exception e) { @@ -138,24 +138,12 @@ static void logResponsePayload( } } - /** - * Generic log method for non-standard request/response/payload logging. - * - *

Any key in the provided {@code contextMap} that matches the {@code SENSITIVE_KEYS} set will - * have its value masked via SHA-256 hash before being logged. - * - * @param loggerProvider the logger provider for the calling class - * @param level the java.util.logging level to map to SLF4J - * @param contextMap the key-value pairs to log - * @param message the log message - */ static void log( LoggerProvider loggerProvider, Level level, Map contextMap, String message) { try { Logger logger = loggerProvider.getLogger(); org.slf4j.event.Level slf4jLevel = matchUtilLevelToSLF4JLevel(level); - Map maskedContextMap = parseGenericData(contextMap); - Slf4jUtils.log(logger, slf4jLevel, maskedContextMap, message); + Slf4jUtils.log(logger, slf4jLevel, contextMap, message); } catch (Exception e) { // let logging fail silently } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 524a312ce..44c9ab3bc 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -627,8 +627,6 @@ void stsRequestHandler_exchangeToken_masksSensitiveTokens() throws IOException { testAppender.stop(); } - // We specifically test ImpersonatedCredentials here because it constructs its HTTP requests - // using JsonHttpContent, unlike most other credentials which use UrlEncodedContent. @Test void impersonatedCredentials_exchangeToken_masksSensitiveTokens() throws IOException, IllegalStateException { @@ -652,21 +650,7 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() assertEquals(3, testAppender.events.size()); - // 1. Verify request log contains properly formatted payload (JsonHttpContent masking) - ILoggingEvent requestLog = testAppender.events.get(0); - assertEquals("Sending request to refresh access token", requestLog.getMessage()); - String requestPayload = null; - for (KeyValuePair kvp : requestLog.getKeyValuePairs()) { - if ("request.payload".equals(kvp.key)) { - requestPayload = (String) kvp.value; - } - } - // When logged at DEBUG level, the request payload should be present and valid JSON. - if (requestPayload != null) { - assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); - } - - // 2. Verify response payload has tokens masked + // Verify response payload has tokens masked assertEquals("Response payload for access token", testAppender.events.get(2).getMessage()); boolean foundAccessToken = false; for (KeyValuePair kvp : testAppender.events.get(2).getKeyValuePairs()) { @@ -685,66 +669,4 @@ void impersonatedCredentials_exchangeToken_masksSensitiveTokens() assertTrue(foundAccessToken, "Expected accessToken in response payload logs"); testAppender.stop(); } - - // We specifically use ImpersonatedCredentials for this test because its request payload - // is formatted using JsonHttpContent, whereas other credentials primarily use UrlEncodedContent. - @Test - void impersonatedCredentials_requestPayload_masksJsonHttpContentSensitiveKeys() - throws IOException, IllegalStateException { - // Set DEBUG level to ensure request payloads are logged - Logger logger = LoggerFactory.getLogger(ImpersonatedCredentials.class); - ch.qos.logback.classic.Logger logbackLogger = (ch.qos.logback.classic.Logger) logger; - ch.qos.logback.classic.Level previousLevel = logbackLogger.getLevel(); - logbackLogger.setLevel(ch.qos.logback.classic.Level.DEBUG); - - TestAppender testAppender = new TestAppender(); - testAppender.start(); - logbackLogger.addAppender(testAppender); - - try { - 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); - - targetCredentials.refreshAccessToken(); - - // Find the request log event - ILoggingEvent requestLog = testAppender.events.get(0); - assertEquals("Sending request to refresh access token", requestLog.getMessage()); - - // Extract request.payload - String requestPayload = null; - for (KeyValuePair kvp : requestLog.getKeyValuePairs()) { - if ("request.payload".equals(kvp.key)) { - requestPayload = (String) kvp.value; - } - } - - // At DEBUG level, request payload must be present - assertNotNull(requestPayload, "Request payload should be logged at DEBUG level"); - assertTrue(isValidJson(requestPayload), "Request payload should be valid JSON"); - - // The request payload uses JsonHttpContent with fields: delegates, scope, lifetime. None of - // these are in SENSITIVE_KEYS, so they should appear as-is (not hashed). - assertFalse( - requestPayload.contains("\"delegates\":null"), - "Payload should be properly serialized from JsonHttpContent"); - } finally { - logbackLogger.setLevel(previousLevel); - testAppender.stop(); - } - } }