View Javadoc
1   /*
2    * Copyright (C) 2025 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   
7   package org.tailormap.api.configuration;
8   
9   import com.fasterxml.jackson.annotation.JsonTypeInfo;
10  import java.lang.invoke.MethodHandles;
11  import java.nio.charset.StandardCharsets;
12  import org.jspecify.annotations.NonNull;
13  import org.slf4j.Logger;
14  import org.slf4j.LoggerFactory;
15  import org.springframework.beans.factory.BeanClassLoaderAware;
16  import org.springframework.context.annotation.Bean;
17  import org.springframework.context.annotation.Configuration;
18  import org.springframework.core.convert.ConversionService;
19  import org.springframework.core.convert.support.GenericConversionService;
20  import org.springframework.security.jackson.SecurityJacksonModules;
21  import org.springframework.session.config.SessionRepositoryCustomizer;
22  import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
23  import tools.jackson.core.JacksonException;
24  import tools.jackson.core.StreamReadFeature;
25  import tools.jackson.databind.DefaultTyping;
26  import tools.jackson.databind.SerializationFeature;
27  import tools.jackson.databind.json.JsonMapper;
28  import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
29  
30  @Configuration(proxyBeanMethods = false)
31  public class JdbcSessionConfiguration implements BeanClassLoaderAware {
32    private static final Logger logger =
33        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
34  
35    private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
36  INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
37  VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
38  ON CONFLICT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME)
39  DO UPDATE SET ATTRIBUTE_BYTES = EXCLUDED.ATTRIBUTE_BYTES
40  """;
41  
42    private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
43  UPDATE %TABLE_NAME%_ATTRIBUTES
44  SET ATTRIBUTE_BYTES = convert_from(?, 'UTF8')::jsonb
45  WHERE SESSION_PRIMARY_ID = ?
46  AND ATTRIBUTE_NAME = ?
47  """;
48  
49    private ClassLoader classLoader;
50  
51    @Bean
52    SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
53      return (sessionRepository) -> {
54        sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
55        sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
56      };
57    }
58  
59    @Bean("springSessionConversionService")
60    public ConversionService springSessionConversionService() {
61  
62      BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
63          .allowIfSubType("org.tailormap.api.security.")
64          .allowIfSubType("org.springframework.security.")
65          .allowIfSubType("java.util.")
66          .allowIfSubType(java.lang.Number.class)
67          .allowIfSubType("java.time.")
68          .allowIfBaseType(Object.class);
69  
70      JsonMapper mapper = JsonMapper.builder()
71          .configure(
72              StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
73              (logger.isDebugEnabled() || logger.isTraceEnabled()))
74          .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
75  
76          // register mixins early so Jackson picks up the @JsonCreator constructor for TailormapUserDetails
77          // implementations
78          .addMixIn(
79              org.tailormap.api.security.TailormapUserDetailsImpl.class,
80              org.tailormap.api.security.TailormapUserDetailsImplMixin.class)
81          .addMixIn(
82              org.tailormap.api.security.TailormapOidcUser.class,
83              org.tailormap.api.security.TailormapOidcUserMixin.class)
84          .addModules(SecurityJacksonModules.getModules(this.classLoader, builder))
85          .activateDefaultTyping(builder.build(), DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY)
86          .build();
87  
88      final GenericConversionService converter = new GenericConversionService();
89      // Object -> byte[] (serialize to JSON bytes)
90      converter.addConverter(Object.class, byte[].class, source -> {
91        try {
92          logger.debug("Serializing Spring Session: {}", source);
93          return mapper.writerFor(Object.class).writeValueAsBytes(source);
94        } catch (JacksonException e) {
95          logger.error("Error serializing Spring Session object: {}", source, e);
96          throw new RuntimeException("Unable to serialize Spring Session.", e);
97        }
98      });
99      // byte[] -> Object (deserialize from JSON bytes)
100     converter.addConverter(byte[].class, Object.class, source -> {
101       try {
102         logger.debug(
103             "Deserializing Spring Session from bytes, length: {} ({})",
104             source.length,
105             new String(source, StandardCharsets.UTF_8));
106         return mapper.readValue(source, Object.class);
107       } catch (JacksonException e) {
108         String preview;
109         try {
110           String content = new String(source, StandardCharsets.UTF_8);
111           int maxLength = 256;
112           preview = content.length() > maxLength ? content.substring(0, maxLength) + "..." : content;
113         } catch (Exception ex) {
114           preview = "<unavailable>";
115         }
116         logger.error(
117             "Error deserializing Spring Session from bytes, length: {}, preview: {}",
118             source.length,
119             preview,
120             e);
121         throw new RuntimeException("Unable to deserialize Spring Session.", e);
122       }
123     });
124 
125     return converter;
126   }
127 
128   @Override
129   public void setBeanClassLoader(@NonNull ClassLoader classLoader) {
130     this.classLoader = classLoader;
131   }
132 }