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 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