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 org.tailormap.api.security.TailormapOidcUser;
24  import org.tailormap.api.security.TailormapOidcUserMixin;
25  import org.tailormap.api.security.TailormapUserDetailsImpl;
26  import org.tailormap.api.security.TailormapUserDetailsImplMixin;
27  import tools.jackson.core.JacksonException;
28  import tools.jackson.core.StreamReadFeature;
29  import tools.jackson.databind.DefaultTyping;
30  import tools.jackson.databind.SerializationFeature;
31  import tools.jackson.databind.json.JsonMapper;
32  import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
33  
34  @Configuration(proxyBeanMethods = false)
35  public class JdbcSessionConfiguration implements BeanClassLoaderAware {
36    private static final Logger logger =
37        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
38  
39    private static final String CREATE_SESSION_ATTRIBUTE_QUERY = """
40  INSERT INTO %TABLE_NAME%_ATTRIBUTES (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES)
41  VALUES (?, ?, convert_from(?, 'UTF8')::jsonb)
42  ON CONFLICT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME)
43  DO UPDATE SET ATTRIBUTE_BYTES = EXCLUDED.ATTRIBUTE_BYTES
44  """;
45  
46    private static final String UPDATE_SESSION_ATTRIBUTE_QUERY = """
47  UPDATE %TABLE_NAME%_ATTRIBUTES
48  SET ATTRIBUTE_BYTES = convert_from(?, 'UTF8')::jsonb
49  WHERE SESSION_PRIMARY_ID = ?
50  AND ATTRIBUTE_NAME = ?
51  """;
52  
53    private ClassLoader classLoader;
54  
55    @Bean
56    SessionRepositoryCustomizer<JdbcIndexedSessionRepository> customizer() {
57      return (sessionRepository) -> {
58        sessionRepository.setCreateSessionAttributeQuery(CREATE_SESSION_ATTRIBUTE_QUERY);
59        sessionRepository.setUpdateSessionAttributeQuery(UPDATE_SESSION_ATTRIBUTE_QUERY);
60      };
61    }
62  
63    @Bean("springSessionConversionService")
64    public ConversionService springSessionConversionService() {
65  
66      BasicPolymorphicTypeValidator.Builder builder = BasicPolymorphicTypeValidator.builder()
67          .allowIfSubType("org.tailormap.api.security.")
68          .allowIfSubType("org.springframework.security.")
69          .allowIfSubType("java.util.")
70          .allowIfSubType(Number.class)
71          .allowIfSubType("java.time.")
72          .allowIfBaseType(Object.class);
73  
74      JsonMapper mapper = JsonMapper.builder()
75          .configure(
76              StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION,
77              (logger.isDebugEnabled() || logger.isTraceEnabled()))
78          .configure(SerializationFeature.INDENT_OUTPUT, (logger.isDebugEnabled() || logger.isTraceEnabled()))
79  
80          // register mixins early so Jackson picks up the @JsonCreator constructor for TailormapUserDetails
81          // implementations
82          .addMixIn(TailormapUserDetailsImpl.class, TailormapUserDetailsImplMixin.class)
83          .addMixIn(TailormapOidcUser.class, 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.trace("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.trace(
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 }