diff --git a/openam-cassandra/openam-cassandra-cts/pom.xml b/openam-cassandra/openam-cassandra-cts/pom.xml
index 42f005ca71..2583d05466 100644
--- a/openam-cassandra/openam-cassandra-cts/pom.xml
+++ b/openam-cassandra/openam-cassandra-cts/pom.xml
@@ -50,6 +50,11 @@
junit
test
+
+ org.mockito
+ mockito-core
+ test
+
diff --git a/openam-cassandra/openam-cassandra-cts/src/main/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapter.java b/openam-cassandra/openam-cassandra-cts/src/main/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapter.java
index a382a87c7d..f4975f0b46 100644
--- a/openam-cassandra/openam-cassandra-cts/src/main/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapter.java
+++ b/openam-cassandra/openam-cassandra-cts/src/main/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapter.java
@@ -12,12 +12,13 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2019 Open Identity Platform Community.
- * Portions copyright 2025 3A Systems LLC.
+ * Portions copyright 2025-2026 3A Systems LLC.
*/
package org.openidentityplatform.openam.cassandra;
import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
import java.text.MessageFormat;
import java.time.Duration;
import java.time.Instant;
@@ -64,10 +65,24 @@
import com.datastax.oss.driver.api.querybuilder.select.Select;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
+import com.google.common.hash.Hashing;
public class TokenStorageAdapter implements org.forgerock.openam.sm.datalayer.api.TokenStorageAdapter {
final static Logger logger = LoggerFactory.getLogger(TokenStorageAdapter.class);
+ /**
+ * Renders a token id for log output as a short SHA-256 digest. The id is a
+ * session id or an OAuth2 token, so the raw value must never reach the logs;
+ * a prefix would not do either, because every session id starts with the same
+ * "AQIC" header. The digest still lets an operator holding the id find its lines.
+ */
+ static String maskTokenId(String tokenId) {
+ if (tokenId == null) {
+ return "null";
+ }
+ return "sha256:" + Hashing.sha256().hashString(tokenId, StandardCharsets.UTF_8).toString().substring(0, 8);
+ }
+
private final DataLayerConfiguration cfg;
static ConnectionFactory connectionFactory;
@@ -111,7 +126,7 @@ public Token update(Token token, boolean ifExists) throws DataLayerException {
try {
value = token.getAttribute(field);
}catch (Throwable e) {
- logger.warn("create {} for {} {}",e.toString(),field,token);
+ logger.warn("update: unable to read {} of {} token {}: {}",field,token.getType(),maskTokenId(token.getTokenId()),e.toString());
throw e;
}
if (value!=null) {
diff --git a/openam-cassandra/openam-cassandra-cts/src/test/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapterTest.java b/openam-cassandra/openam-cassandra-cts/src/test/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapterTest.java
new file mode 100644
index 0000000000..9264940de3
--- /dev/null
+++ b/openam-cassandra/openam-cassandra-cts/src/test/java/org/openidentityplatform/openam/cassandra/TokenStorageAdapterTest.java
@@ -0,0 +1,101 @@
+/*
+ * The contents of this file are subject to the terms of the Common Development and
+ * Distribution License (the License). You may not use this file except in compliance with the
+ * License.
+ *
+ * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
+ * specific language governing permission and limitations under the License.
+ *
+ * When distributing Covered Software, include this CDDL Header Notice in each file and include
+ * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
+ * Header, with the fields enclosed by brackets [] replaced by your own identifying
+ * information: "Portions copyright [year] [name of copyright owner]".
+ *
+ * Copyright 2026 3A Systems, LLC.
+ */
+package org.openidentityplatform.openam.cassandra;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.RETURNS_SELF;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.Calendar;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import org.forgerock.openam.cts.api.tokens.Token;
+import org.forgerock.openam.sm.datalayer.api.DataLayerException;
+import org.forgerock.openam.tokens.CoreTokenField;
+import org.forgerock.openam.tokens.TokenType;
+import org.junit.After;
+import org.junit.Test;
+import org.slf4j.LoggerFactory;
+
+import com.datastax.oss.driver.api.core.cql.BoundStatement;
+import com.datastax.oss.driver.api.core.cql.PreparedStatement;
+
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+
+public class TokenStorageAdapterTest {
+
+ @After
+ public void forgetPreparedStatement() {
+ TokenStorageAdapter.static_statement_update = null;
+ }
+
+ /**
+ * A CTS token id is a session id or an OAuth2 token: it must never be written
+ * to the log, only a short digest that identifies the record. Every session id
+ * starts with the same "AQIC" header, so a prefix could not tell two apart.
+ */
+ @Test
+ public void maskTokenIdReplacesTheIdWithAShortDigest() {
+ assertEquals("sha256:983d2944", TokenStorageAdapter.maskTokenId("AQIC5wM2LY4SfczntBcXfFoFJwA6zAV2i4fnU8Sd7ao"));
+ assertEquals("sha256:d5989e92", TokenStorageAdapter.maskTokenId("AQIC5wM2LY4Sfczn-expired-session-token"));
+ assertEquals("sha256:b8150354", TokenStorageAdapter.maskTokenId("AQIC5wM2LY4Sfczn-fresh-session-token"));
+ assertEquals("null", TokenStorageAdapter.maskTokenId(null));
+ }
+
+ /**
+ * The warning written when a token field cannot be read during {@code update}
+ * must carry the digest of the token id, never the id itself.
+ */
+ @Test
+ public void updateLogsTheDigestNotTheIdWhenAFieldCannotBeRead() throws Exception {
+ String tokenId = "AQIC5wM2LY4SfczntBcXfFoFJwA6zAV2i4fnU8Sd7ao";
+ Token token = mock(Token.class);
+ when(token.getTokenId()).thenReturn(tokenId);
+ when(token.getType()).thenReturn(TokenType.SESSION);
+ when(token.getExpiryTimestamp()).thenReturn(Calendar.getInstance());
+ when(token.getAttribute(any(CoreTokenField.class))).thenThrow(new IllegalArgumentException("boom"));
+
+ // The statement is pre-set, so the adapter never reaches Cassandra: the read
+ // of the first field fails before the statement is executed.
+ BoundStatement bound = mock(BoundStatement.class, RETURNS_SELF);
+ PreparedStatement prepared = mock(PreparedStatement.class);
+ when(prepared.bind()).thenReturn(bound);
+ TokenStorageAdapter.static_statement_update = prepared;
+ TokenStorageAdapter adapter = new TokenStorageAdapter(null, null);
+
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(TokenStorageAdapter.class)).addAppender(appender);
+ try {
+ adapter.update(token, true);
+ fail("update must propagate the failed field read");
+ } catch (DataLayerException expected) {
+ // the warning is written before the exception is wrapped
+ } finally {
+ ((ch.qos.logback.classic.Logger) LoggerFactory.getLogger(TokenStorageAdapter.class)).detachAppender(appender);
+ }
+
+ List messages = appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList());
+ assertTrue(messages.toString(), messages.stream().anyMatch(m -> m.contains("SESSION token sha256:983d2944: java.lang.IllegalArgumentException: boom")));
+ assertTrue(messages.toString(), messages.stream().noneMatch(m -> m.contains(tokenId)));
+ }
+}
diff --git a/openam-cassandra/openam-cassandra-datastore/src/main/java/org/openidentityplatform/openam/cassandra/Repo.java b/openam-cassandra/openam-cassandra-datastore/src/main/java/org/openidentityplatform/openam/cassandra/Repo.java
index 9195f89b22..a5020599c2 100644
--- a/openam-cassandra/openam-cassandra-datastore/src/main/java/org/openidentityplatform/openam/cassandra/Repo.java
+++ b/openam-cassandra/openam-cassandra-datastore/src/main/java/org/openidentityplatform/openam/cassandra/Repo.java
@@ -12,6 +12,7 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2019 Open Identity Platform Community.
+ * Portions copyright 2026 3A Systems LLC.
*/
package org.openidentityplatform.openam.cassandra;
@@ -434,7 +435,7 @@ public void setAttributes(SSOToken token, IdType type, String name, Map attributes, boolean isAdd) throws IdRepoException, SSOException {
//validate(type, IdOperation.EDIT);
- logger.warn("unsupported setBinaryAttributes {} {} {} {}",type,name,attributes,isAdd);
+ logger.warn("unsupported setBinaryAttributes {} {} {} {}",type,name,names(attributes),isAdd);
throw new IdRepoUnsupportedOpException("unsupported setBinaryAttributes");
}
@@ -656,7 +657,7 @@ public RepoSearchResults search(SSOToken token, IdType type, String pattern, int
}
return new RepoSearchResults(result.keySet(),(maxResults>0&&result.size()>maxResults)?RepoSearchResults.SIZE_LIMIT_EXCEEDED:RepoSearchResults.SUCCESS,result,type);
}catch(Throwable e){
- logger.error("search {} {} {} {} {} {} {} {} {}: {}",type,pattern,maxTime,maxResults,returnAttrs,returnAllAttrs,filterOp,avPairs,recursive,logger.isDebugEnabled()?e:e.getMessage());
+ logger.error("search {} {} {} {} {} {} {} {} {}: {}",type,pattern,maxTime,maxResults,returnAttrs,returnAllAttrs,filterOp,names(avPairs),recursive,logger.isDebugEnabled()?e:e.getMessage());
throw new IdRepoException(e.getMessage());
}
}
@@ -744,7 +745,7 @@ public void assignService(SSOToken token, IdType type, String name, String servi
attrMap.put("serviceName", attr.get("serviceName"));
setAttributes(token, type, name, attrMap, false);
}catch(Throwable e){
- logger.error("assignService {} {} {} {}",type,name,serviceName,attrMap,e.getMessage());
+ logger.error("assignService {} {} {} {}: {}",type,name,serviceName,names(attrMap),e.getMessage());
throw new IdRepoException(e.getMessage());
}
}
@@ -772,7 +773,7 @@ public void unassignService(SSOToken token, IdType type, String name, String ser
attrMap.put("serviceName", attr.get("serviceName"));
setAttributes(token, type, name, attrMap, false);
}catch(Throwable e){
- logger.error("unassignService {} {} {} {}",type,name,serviceName,attrMap,e.getMessage());
+ logger.error("unassignService {} {} {} {}: {}",type,name,serviceName,names(attrMap),e.getMessage());
throw new IdRepoException(e.getMessage());
}
}
@@ -801,7 +802,7 @@ public void modifyService(SSOToken token, IdType type, String name, String servi
attrMap.put("serviceName", attr.get("serviceName"));
setAttributes(token, type, name, attrMap, false);
}catch(Throwable e){
- logger.error("modifyService {} {} {} {}",type,name,serviceName,attrMap,e.getMessage());
+ logger.error("modifyService {} {} {} {}: {}",type,name,serviceName,names(attrMap),e.getMessage());
throw new IdRepoException(e.getMessage());
}
}
@@ -817,6 +818,13 @@ public void removeListener() {
}
///////////////////////////////////////////////////////////////////////////
+ /**
+ * Attribute names for log output: the values may carry userPassword.
+ */
+ static Set names(Map attributes) {
+ return attributes==null?null:attributes.keySet();
+ }
+
void validate(IdType type,IdOperation service) throws IdRepoUnsupportedOpException{
if (!supportedOps.containsKey(type)||!supportedOps.get(type).contains(service))
throw new IdRepoUnsupportedOpException("operation "+service.getName()+" not supported for "+type.getName());
diff --git a/openam-cassandra/openam-cassandra-datastore/src/test/java/org/openidentityplatform/openam/cassandra/IdRepoTest.java b/openam-cassandra/openam-cassandra-datastore/src/test/java/org/openidentityplatform/openam/cassandra/IdRepoTest.java
index 512301fddc..7959478334 100644
--- a/openam-cassandra/openam-cassandra-datastore/src/test/java/org/openidentityplatform/openam/cassandra/IdRepoTest.java
+++ b/openam-cassandra/openam-cassandra-datastore/src/test/java/org/openidentityplatform/openam/cassandra/IdRepoTest.java
@@ -13,15 +13,20 @@
* information: "Portions copyright [year] [name of copyright owner]".
*
* Copyright 2019 Open Identity Platform Community.
+ * Portions copyright 2026 3A Systems LLC.
*/
import static org.junit.Assert.*;
+import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
+import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
+import java.util.stream.Collectors;
import javax.security.auth.callback.Callback;
import javax.security.auth.callback.NameCallback;
@@ -30,13 +35,18 @@
import org.junit.AfterClass;
import org.junit.BeforeClass;
import org.junit.Test;
+import org.junit.function.ThrowingRunnable;
import com.iplanet.sso.SSOException;
import com.sun.identity.authentication.spi.AuthLoginException;
import com.sun.identity.idm.IdRepoDuplicateObjectException;
import com.sun.identity.idm.IdRepoException;
+import com.sun.identity.idm.IdRepoUnsupportedOpException;
import com.sun.identity.idm.IdType;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
+
import org.openidentityplatform.openam.cassandra.embedded.Server;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -413,4 +423,77 @@ public void updated_created_fields_test() throws SSOException, IdRepoException{
System.out.println(repo.getAttributes(null, IdType.USER, "9170000000",fields));
}
+
+ /**
+ * Runs {@code call}, which must fail with {@code expected}, and returns the
+ * lines {@link Repo} logged meanwhile. Attribute values may carry userPassword,
+ * so a failure report must name the attributes only.
+ */
+ private static List repoLogOf(Class extends Throwable> expected, ThrowingRunnable call) {
+ final ch.qos.logback.classic.Logger repoLogger=(ch.qos.logback.classic.Logger) LoggerFactory.getLogger(Repo.class.getName());
+ final ListAppender appender=new ListAppender<>();
+ appender.start();
+ repoLogger.addAppender(appender);
+ try {
+ assertThrows(expected, call);
+ }finally {
+ repoLogger.detachAppender(appender);
+ }
+ return appender.list.stream().map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList());
+ }
+
+ private static void assertNamesOnly(List log, String operation, String value) {
+ assertTrue(log.toString(), log.stream().anyMatch(m -> m.startsWith(operation) && m.contains("userPassword") && !m.contains("userPassword=")));
+ assertTrue(log.toString(), log.stream().noneMatch(m -> m.contains(value)));
+ }
+
+ @Test
+ public void setAttributes_failure_logs_attribute_names_only() {
+ // a null key cannot enter the case-insensitive map, so the operation fails
+ // before any statement runs
+ final Map> param=new HashMap<>();
+ param.put(null, Collections.singleton("x"));
+ param.put("userPassword", Collections.singleton("s3cret"));
+ assertNamesOnly(repoLogOf(IdRepoException.class,
+ () -> repo.setAttributes(null, IdType.USER, "9170000000", param, false)), "setAttributes", "s3cret");
+ }
+
+ @Test
+ public void setBinaryAttributes_logs_attribute_names_only() {
+ final Map param=Collections.singletonMap("userPassword", new byte[][] {"s3cret".getBytes(StandardCharsets.UTF_8)});
+ assertNamesOnly(repoLogOf(IdRepoUnsupportedOpException.class,
+ () -> repo.setBinaryAttributes(null, IdType.USER, "9170000000", param, false)), "unsupported setBinaryAttributes", "[B@");
+ }
+
+ @Test
+ public void assignService_failure_logs_attribute_names_only() {
+ // the immutable map rejects the serviceName the method adds to it
+ final Map> param=Collections.singletonMap("userPassword", Collections.singleton("s3cret"));
+ assertNamesOnly(repoLogOf(IdRepoException.class,
+ () -> repo.assignService(null, IdType.USER, "9170000000", "svc", null, param)), "assignService", "s3cret");
+ }
+
+ @Test
+ public void unassignService_failure_logs_attribute_names_only() {
+ final Map> param=Collections.singletonMap("userPassword", Collections.singleton("s3cret"));
+ assertNamesOnly(repoLogOf(IdRepoException.class,
+ () -> repo.unassignService(null, IdType.USER, "9170000000", "svc", param)), "unassignService", "s3cret");
+ }
+
+ @Test
+ public void modifyService_failure_logs_attribute_names_only() {
+ final Map> param=Collections.singletonMap("userPassword", Collections.singleton("s3cret"));
+ assertNamesOnly(repoLogOf(IdRepoException.class,
+ () -> repo.modifyService(null, IdType.USER, "9170000000", "svc", null, param)), "modifyService", "s3cret");
+ }
+
+ @Test
+ public void search_failure_logs_filter_names_only() {
+ // a null value set fails the filter loop before any statement runs
+ final Map> avPairs=new HashMap<>();
+ avPairs.put("mail", null);
+ avPairs.put("userPassword", Collections.singleton("s3cret"));
+ assertNamesOnly(repoLogOf(IdRepoException.class,
+ () -> repo.search(null, IdType.USER, "*", 0, 1, null, true, Repo.AND_MOD, avPairs, false)), "search", "s3cret");
+ }
}
diff --git a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java
index 5f38fcdc32..8d3813bcb2 100644
--- a/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java
+++ b/openam-mcp-server/src/main/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptor.java
@@ -32,9 +32,13 @@
import org.springframework.web.servlet.HandlerInterceptor;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
+import java.util.HexFormat;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@@ -66,6 +70,25 @@ public AuthInterceptor(RestClient openAMRestClient, OpenAMConfig openAMConfig) {
this.tokenCache = tokenCache;
}
+ /**
+ * Renders a session id or access token for log output as a short SHA-256
+ * digest. A token written to a log file is enough to hijack the session it
+ * represents, so the raw value must never reach the logs; a prefix would not
+ * do either, because every session id starts with the same "AQIC" header.
+ * The digest still lets an operator holding the token match its log lines.
+ */
+ static String maskToken(String token) {
+ if (token == null) {
+ return "null";
+ }
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(token.getBytes(StandardCharsets.UTF_8));
+ return "sha256:" + HexFormat.of().formatHex(digest, 0, 4);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
if(openAMConfig.useOAuthForAuthentication()) {
@@ -125,7 +148,7 @@ boolean preHandleUsernamePassword(HttpServletRequest request) {
return true;
}
log.info("preHandleUsernamePassword: token {} is about to expire in {} s (attempt {}/{})",
- token, seconds, attempt + 1, MAX_TOKEN_RESOLUTION_ATTEMPTS);
+ maskToken(token), seconds, attempt + 1, MAX_TOKEN_RESOLUTION_ATTEMPTS);
tokenCache.invalidate(LOGIN_PASSWORD_TOKEN_KEY);
token = getUserNamePasswordToken();
tokenCache.put(LOGIN_PASSWORD_TOKEN_KEY, token);
@@ -165,7 +188,7 @@ boolean preHandleOAuth(HttpServletRequest request, HttpServletResponse response)
request.setAttribute("tokenId", token);
return true;
}
- log.info("preHandleOAuth: token {} is about to expire in {} s", token, seconds);
+ log.info("preHandleOAuth: token {} is about to expire in {} s", maskToken(token), seconds);
tokenCache.invalidate(accessToken);
token = getTokenIdFromAccessToken(accessToken);
tokenCache.put(accessToken, token);
@@ -190,7 +213,8 @@ boolean accessTokenValid(String accessToken) {
if(response.containsKey("name")) {
return true;
} else {
- log.warn("got invalid response: {} for access token: {}", response, accessToken);
+ // Claim names only: the body carries the user's claims (sub, email, ...).
+ log.warn("got invalid response (claims {}) for access token: {}", response.keySet(), maskToken(accessToken));
return false;
}
} catch (Exception e) {
diff --git a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java
index cbaa480d9f..7a030e303c 100644
--- a/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java
+++ b/openam-mcp-server/src/test/java/org/openidentityplatform/openam/mcp/server/security/AuthInterceptorTest.java
@@ -16,6 +16,8 @@
package org.openidentityplatform.openam.mcp.server.security;
+import ch.qos.logback.classic.spi.ILoggingEvent;
+import ch.qos.logback.core.read.ListAppender;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.junit.jupiter.api.BeforeEach;
@@ -24,17 +26,23 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.openidentityplatform.openam.mcp.server.config.OpenAMConfig;
+import org.slf4j.LoggerFactory;
+import org.springframework.core.ParameterizedTypeReference;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
import org.springframework.web.client.RestClient;
+import java.util.List;
+import java.util.Map;
import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.atMost;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.times;
@@ -227,4 +235,106 @@ public void preHandleUsernamePassword_logsInAndCachesToken_whenCacheIsEmpty() th
assertThat(req.getAttribute("tokenId")).isEqualTo(newToken);
assertThat(tokenCache.getIfPresent("login-password-token")).isEqualTo(newToken);
}
+
+ @Test
+ void maskToken_replacesTheTokenWithAShortDigest() {
+ assertThat(AuthInterceptor.maskToken("AQIC5wM2LY4SfczntBcXfFoFJwA6zAV2i4fnU8Sd7ao")).isEqualTo("sha256:983d2944");
+ // Every session id starts with the same "AQIC" header, so a prefix could not
+ // tell two of them apart; the digest can.
+ assertThat(AuthInterceptor.maskToken("AQIC5wM2LY4Sfczn-expired-session-token")).isEqualTo("sha256:d5989e92");
+ assertThat(AuthInterceptor.maskToken("AQIC5wM2LY4Sfczn-fresh-session-token")).isEqualTo("sha256:b8150354");
+ assertThat(AuthInterceptor.maskToken(null)).isEqualTo("null");
+ }
+
+ /**
+ * A session id or an access token in the log file lets anyone who can read
+ * the logs hijack that session, so the interceptor must never log them raw.
+ */
+ @Test
+ void preHandleUsernamePassword_doesNotLogRawToken_whenRefreshing() throws Exception {
+ String expiredToken = "AQIC5wM2LY4Sfczn-expired-session-token";
+ String freshToken = "AQIC5wM2LY4Sfczn-fresh-session-token";
+ tokenCache.put("login-password-token", expiredToken);
+
+ AuthInterceptor spy = spy(interceptor);
+ doReturn(1L).when(spy).tokenValidSeconds(expiredToken);
+ doReturn(freshToken).when(spy).getUserNamePasswordToken();
+ doReturn(300L).when(spy).tokenValidSeconds(freshToken);
+
+ List messages = captureLogs(() -> spy.preHandleUsernamePassword(new MockHttpServletRequest()));
+
+ assertThat(messages).anyMatch(m -> m.contains(
+ "token " + AuthInterceptor.maskToken(expiredToken) + " is about to expire"));
+ assertThat(messages).noneMatch(m -> m.contains(expiredToken));
+ assertThat(messages).noneMatch(m -> m.contains(freshToken));
+ }
+
+ @Test
+ void preHandleOAuth_doesNotLogRawSessionToken_whenRefreshing() {
+ String accessToken = "f3c1a9e0-access-token-value";
+ String expiredToken = "AQIC5wM2LY4Sfczn-expired-session-token";
+ tokenCache.put(accessToken, expiredToken);
+
+ MockHttpServletRequest req = new MockHttpServletRequest();
+ req.addHeader("Authorization", "Bearer " + accessToken);
+
+ AuthInterceptor spy = spy(interceptor);
+ doReturn(true).when(spy).accessTokenValid(accessToken);
+ doReturn(1L).when(spy).tokenValidSeconds(expiredToken);
+
+ List messages = captureLogs(() -> {
+ try {
+ spy.preHandleOAuth(req, new MockHttpServletResponse());
+ } catch (Exception ignored) {
+ // The mocked RestClient cannot mint a new token; the log line
+ // under test is written before that call.
+ }
+ });
+
+ assertThat(messages).anyMatch(m -> m.contains(
+ "token " + AuthInterceptor.maskToken(expiredToken) + " is about to expire"));
+ assertThat(messages).noneMatch(m -> m.contains(expiredToken));
+ }
+
+ @Test
+ void accessTokenValid_doesNotLogRawAccessTokenOrClaims_onInvalidResponse() {
+ String accessToken = "f3c1a9e0-access-token-value";
+
+ // userinfo answers without a "name" (a token without the profile scope): the
+ // token must be reported as invalid without echoing it, or the user's
+ // claims, into the log.
+ @SuppressWarnings("rawtypes")
+ RestClient.RequestHeadersUriSpec uriSpec = mock(RestClient.RequestHeadersUriSpec.class);
+ RestClient.ResponseSpec responseSpec = mock(RestClient.ResponseSpec.class);
+ doReturn(uriSpec).when(restClient).get();
+ doReturn(uriSpec).when(uriSpec).uri(anyString());
+ doReturn(uriSpec).when(uriSpec).header(anyString(), any(String[].class));
+ doReturn(responseSpec).when(uriSpec).retrieve();
+ doReturn(Map.of("sub", "demo", "email", "demo@example.com"))
+ .when(responseSpec).body(any(ParameterizedTypeReference.class));
+
+ List messages = captureLogs(() -> assertThat(interceptor.accessTokenValid(accessToken)).isFalse());
+
+ assertThat(messages).anyMatch(m -> m.contains("got invalid response")
+ && m.contains("email") && m.contains("for access token: " + AuthInterceptor.maskToken(accessToken)));
+ assertThat(messages).noneMatch(m -> m.contains(accessToken));
+ assertThat(messages).noneMatch(m -> m.contains("demo@example.com"));
+ }
+
+ private static List captureLogs(Runnable action) {
+ ch.qos.logback.classic.Logger logger =
+ (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(AuthInterceptor.class);
+ ListAppender appender = new ListAppender<>();
+ appender.start();
+ logger.addAppender(appender);
+ try {
+ action.run();
+ } finally {
+ logger.detachAppender(appender);
+ }
+ return appender.list.stream()
+ .map(e -> e.getFormattedMessage()
+ + (e.getThrowableProxy() == null ? "" : " " + e.getThrowableProxy().getMessage()))
+ .collect(Collectors.toList());
+ }
}
\ No newline at end of file