IGNITE-14920: Implement Spring Sessions Using Ignite As Backing Store (#63)

diff --git a/modules/spring-session-ext/pom.xml b/modules/spring-session-ext/pom.xml
new file mode 100644
index 0000000..1077de8
--- /dev/null
+++ b/modules/spring-session-ext/pom.xml
@@ -0,0 +1,127 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>apache-ignite-extensions</artifactId>
+        <groupId>org.apache.ignite</groupId>
+        <version>1.0.0-SNAPSHOT</version>
+        <relativePath>../../pom.xml</relativePath>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>spring-session-ext</artifactId>
+
+    <properties>
+        <ignite.version>2.10.0</ignite.version>
+        <spring.version>5.3.8</spring.version>
+        <spring.session.version>2.5.0</spring.session.version>
+        <spring.security.version>5.5.0</spring.security.version>
+        <javax.annotation.version>1.3.2</javax.annotation.version>
+        <assertj.version>3.20.0</assertj.version>
+        <junit.jupiter.version>5.7.2</junit.jupiter.version>
+        <javax.servlet.version>3.0.1</javax.servlet.version>
+        <mockito.version>2.22.0</mockito.version>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.ignite</groupId>
+            <artifactId>ignite-core</artifactId>
+            <version>${ignite.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-core</artifactId>
+            <version>${spring.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-expression</artifactId>
+            <version>${spring.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.springframework.security/spring-security-core -->
+        <dependency>
+            <groupId>org.springframework.security</groupId>
+            <artifactId>spring-security-core</artifactId>
+            <version>${spring.security.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.springframework.session/spring-session-core -->
+        <dependency>
+            <groupId>org.springframework.session</groupId>
+            <artifactId>spring-session-core</artifactId>
+            <version>${spring.session.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.springframework/spring-context -->
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-context</artifactId>
+            <version>${spring.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.annotation</groupId>
+            <artifactId>javax.annotation-api</artifactId>
+            <version>${javax.annotation.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.assertj/assertj-core -->
+        <dependency>
+            <groupId>org.assertj</groupId>
+            <artifactId>assertj-core</artifactId>
+            <version>${assertj.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.junit.jupiter/junit-jupiter-api -->
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter-api</artifactId>
+            <version>${junit.jupiter.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.springframework/spring-test -->
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-test</artifactId>
+            <version>${spring.version}</version>
+            <scope>test</scope>
+        </dependency>
+
+        <dependency>
+            <groupId>javax.servlet</groupId>
+            <artifactId>javax.servlet-api</artifactId>
+            <version>${javax.servlet.version}</version>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.springframework/spring-web -->
+        <dependency>
+            <groupId>org.springframework</groupId>
+            <artifactId>spring-web</artifactId>
+            <version>${spring.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.apache.ignite/ignite-indexing -->
+        <dependency>
+            <groupId>org.apache.ignite</groupId>
+            <artifactId>ignite-indexing</artifactId>
+            <version>${ignite.version}</version>
+        </dependency>
+
+        <!-- https://mvnrepository.com/artifact/org.mockito/mockito-core -->
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <version>${mockito.version}</version>
+            <scope>test</scope>
+        </dependency>
+    </dependencies>
+</project>
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java
new file mode 100644
index 0000000..787496f
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/EnableIgniteHttpSession.java
@@ -0,0 +1,98 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.apache.ignite.Ignite;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Import;
+import org.springframework.session.FlushMode;
+import org.springframework.session.MapSession;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.SessionRepository;
+import org.springframework.session.web.http.SessionRepositoryFilter;
+
+/**
+ * Add this annotation to an {@code @Configuration} class to expose the
+ * {@link SessionRepositoryFilter} as a bean named {@code springSessionRepositoryFilter}
+ * and backed by Ignite. In order to leverage the annotation, a single {@link Ignite} must
+ * be provided. For example:
+ *
+ * <pre class="code">
+ * &#064;Configuration
+ * &#064;EnableIgniteHttpSession
+ * public class IgniteHttpSessionConfig {
+ *
+ *     &#064;Bean
+ *     public Ignite embeddedIgnite() {
+ *         return IgniteEx.start();
+ *     }
+ *
+ * }
+ * </pre>
+ *
+ * More advanced configurations can extend {@link IgniteHttpSessionConfiguration} instead.
+ *
+ */
+@Retention(RetentionPolicy.RUNTIME)
+@Target(ElementType.TYPE)
+@Documented
+@Import(IgniteHttpSessionConfiguration.class)
+@Configuration(proxyBeanMethods = false)
+public @interface EnableIgniteHttpSession {
+    /**
+     * The session timeout in seconds. By default, it is set to 1800 seconds (30 minutes).
+     * This should be a non-negative integer.
+     * @return the seconds a session can be inactive before expiring
+     */
+    int maxInactiveIntervalInSeconds() default MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
+
+    /**
+     * This is the name of the Map that will be used in Ignite to store the session data.
+     * Default is "spring:session:sessions".
+     * @return the name of the Map to store the sessions in Ignite
+     */
+    String sessionMapName() default "spring:session:sessions";
+
+    /**
+     * Flush mode for the Ignite sessions. The default is {@code ON_SAVE} which only
+     * updates the backing Ignite when {@link SessionRepository#save(Session)} is invoked.
+     * In a web environment this happens just before the HTTP response is committed.
+     * <p>
+     * Setting the value to {@code IMMEDIATE} will ensure that the any updates to the
+     * Session are immediately written to the Ignite instance.
+     * @return the {@link FlushMode} to use
+     * @since 2.2.0
+     */
+    FlushMode flushMode() default FlushMode.ON_SAVE;
+
+    /**
+     * Save mode for the session. The default is {@link SaveMode#ON_SET_ATTRIBUTE}, which
+     * only saves changes made to session.
+     * @return the save mode
+     * @since 2.2.0
+     */
+    SaveMode saveMode() default SaveMode.ON_SET_ATTRIBUTE;
+
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java
new file mode 100644
index 0000000..36aa7e3
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfiguration.java
@@ -0,0 +1,164 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+import org.apache.ignite.Ignite;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.ImportAware;
+import org.springframework.core.annotation.AnnotationAttributes;
+import org.springframework.core.type.AnnotationMetadata;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.MapSession;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.SessionRepository;
+import org.springframework.session.config.SessionRepositoryCustomizer;
+import org.springframework.session.config.annotation.web.http.SpringHttpSessionConfiguration;
+import org.springframework.session.web.http.SessionRepositoryFilter;
+import org.springframework.util.StringUtils;
+
+/**
+ * Exposes the {@link SessionRepositoryFilter} as a bean named
+ * {@code springSessionRepositoryFilter}. In order to use this a single {@link Ignite}
+ * must be exposed as a Bean.
+ */
+@Configuration(proxyBeanMethods = false)
+public class IgniteHttpSessionConfiguration extends SpringHttpSessionConfiguration implements ImportAware {
+    /** */
+    private Integer maxInactiveIntervalInSeconds = MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS;
+
+    /** */
+    private String sessionMapName = IgniteIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME;
+
+    /** */
+    private FlushMode flushMode = FlushMode.ON_SAVE;
+
+    /** */
+    private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;
+
+    /** */
+    private Ignite ignite;
+
+    /** */
+    private ApplicationEventPublisher applicationEventPublisher;
+
+    /** */
+    private IndexResolver<Session> indexResolver;
+
+    /** */
+    private List<SessionRepositoryCustomizer<IgniteIndexedSessionRepository>> sessionRepositoryCustomizers;
+
+    /** */
+    @Bean
+    public SessionRepository<?> sessionRepository() {
+        return createIgniteIndexedSessionRepository();
+    }
+
+    /** */
+    public void setMaxInactiveIntervalInSeconds(int maxInactiveIntervalInSeconds) {
+        this.maxInactiveIntervalInSeconds = maxInactiveIntervalInSeconds;
+    }
+
+    /** */
+    public void setSessionMapName(String sessionMapName) {
+        this.sessionMapName = sessionMapName;
+    }
+
+    /**
+     *
+     */
+    public void setFlushMode(FlushMode flushMode) {
+        this.flushMode = flushMode;
+    }
+
+    /** */
+    public void setSaveMode(SaveMode saveMode) {
+        this.saveMode = saveMode;
+    }
+
+    /** */
+    @Autowired
+    public void setIgnite(@SpringSessionIgnite ObjectProvider<Ignite> springSessionIgnite,
+                          ObjectProvider<Ignite> ignite) {
+        Ignite igniteToUse = springSessionIgnite.getIfAvailable();
+        if (igniteToUse == null)
+            igniteToUse = ignite.getObject();
+
+        this.ignite = igniteToUse;
+    }
+
+    /** */
+    @Autowired
+    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+        this.applicationEventPublisher = applicationEventPublisher;
+    }
+
+    /** */
+    @Autowired(required = false)
+    public void setIndexResolver(IndexResolver<Session> indexResolver) {
+        this.indexResolver = indexResolver;
+    }
+
+    /** */
+    @Autowired(required = false)
+    public void setSessionRepositoryCustomizer(
+            ObjectProvider<SessionRepositoryCustomizer<IgniteIndexedSessionRepository>> sessionRepositoryCustomizers) {
+        this.sessionRepositoryCustomizers = sessionRepositoryCustomizers.orderedStream().collect(Collectors.toList());
+    }
+
+    /** */
+    @Override @SuppressWarnings("deprecation") public void setImportMetadata(AnnotationMetadata importMetadata) {
+        Map<String, Object> attributeMap = importMetadata
+                .getAnnotationAttributes(EnableIgniteHttpSession.class.getName());
+        AnnotationAttributes attributes = AnnotationAttributes.fromMap(attributeMap);
+        this.maxInactiveIntervalInSeconds = attributes.getNumber("maxInactiveIntervalInSeconds");
+        String sessionMapNameValue = attributes.getString("sessionMapName");
+        if (StringUtils.hasText(sessionMapNameValue))
+            this.sessionMapName = sessionMapNameValue;
+
+        this.flushMode = attributes.getEnum("flushMode");
+        this.saveMode = attributes.getEnum("saveMode");
+    }
+
+    /** */
+    private IgniteIndexedSessionRepository createIgniteIndexedSessionRepository() {
+        IgniteIndexedSessionRepository sessionRepository = new IgniteIndexedSessionRepository(this.ignite);
+        sessionRepository.setApplicationEventPublisher(this.applicationEventPublisher);
+        if (this.indexResolver != null)
+            sessionRepository.setIndexResolver(this.indexResolver);
+
+        if (StringUtils.hasText(this.sessionMapName))
+            sessionRepository.setSessionMapName(this.sessionMapName);
+
+        sessionRepository.setDefaultMaxInactiveInterval(this.maxInactiveIntervalInSeconds);
+        sessionRepository.setFlushMode(this.flushMode);
+        sessionRepository.setSaveMode(this.saveMode);
+        this.sessionRepositoryCustomizers
+                .forEach((sessionRepositoryCustomizer) -> sessionRepositoryCustomizer.customize(sessionRepository));
+        return sessionRepository;
+    }
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java
new file mode 100644
index 0000000..8fe5ff0
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepository.java
@@ -0,0 +1,555 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import javax.annotation.PostConstruct;
+import javax.annotation.PreDestroy;
+import javax.cache.configuration.CacheEntryListenerConfiguration;
+import javax.cache.configuration.Factory;
+import javax.cache.event.CacheEntryCreatedListener;
+import javax.cache.event.CacheEntryEvent;
+import javax.cache.event.CacheEntryEventFilter;
+import javax.cache.event.CacheEntryExpiredListener;
+import javax.cache.event.CacheEntryListener;
+import javax.cache.event.CacheEntryListenerException;
+import javax.cache.event.CacheEntryRemovedListener;
+import javax.cache.expiry.TouchedExpiryPolicy;
+
+import org.apache.commons.logging.Log;
+import org.apache.commons.logging.LogFactory;
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.cache.query.FieldsQueryCursor;
+import org.apache.ignite.cache.query.SqlFieldsQuery;
+import org.apache.ignite.cache.query.annotations.QuerySqlField;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.GridDirectTransient;
+
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.session.DelegatingIndexResolver;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.MapSession;
+import org.springframework.session.PrincipalNameIndexResolver;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.events.AbstractSessionEvent;
+import org.springframework.session.events.SessionCreatedEvent;
+import org.springframework.session.events.SessionDeletedEvent;
+import org.springframework.session.events.SessionExpiredEvent;
+import org.springframework.util.Assert;
+
+/**
+ * A {@link org.springframework.session.SessionRepository} implementation that stores
+ * sessions in Apache Ignite distributed {@link IgniteCache}.
+ *
+ * <p>
+ * An example of how to create a new instance can be seen below:
+ *
+ * <pre class="code">
+ * IgniteConfiguration config = new IgniteConfiguration();
+ *
+ * // ... configure Ignite ...
+ *
+ * Ignite ignite = IgnitionEx.start(config);
+ *
+ * IgniteIndexedSessionRepository sessionRepository =
+ *         new IgniteIndexedSessionRepository(ignite);
+ * </pre>
+ *
+ * In order to support finding sessions by principal name using
+ * {@link #findByIndexNameAndIndexValue(String, String)} method, custom configuration of
+ * {@link IgniteCache} supplied to this implementation is required.
+ *
+ * This implementation listens for events on the Ignite-backed SessionRepository and
+ * translates those events into the corresponding Spring Session events. Publish the
+ * Spring Session events with the given {@link ApplicationEventPublisher}.
+ *
+ * <ul>
+ * <li>entryAdded - {@link SessionCreatedEvent}</li>
+ * <li>entryEvicted - {@link SessionExpiredEvent}</li>
+ * <li>entryRemoved - {@link SessionDeletedEvent}</li>
+ * </ul>
+ *
+ */
+public class IgniteIndexedSessionRepository
+        implements FindByIndexNameSessionRepository<IgniteIndexedSessionRepository.IgniteSession>,
+        CacheEntryCreatedListener<String, IgniteIndexedSessionRepository.IgniteSession>,
+        CacheEntryRemovedListener<String, IgniteIndexedSessionRepository.IgniteSession>,
+        CacheEntryExpiredListener<String, IgniteIndexedSessionRepository.IgniteSession> {
+    /**
+     * The default name of map used by Spring Session to store sessions.
+     */
+    public static final String DEFAULT_SESSION_MAP_NAME = "spring:session:sessions";
+
+    /** */
+    private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+    /** */
+    private static final Log logger = LogFactory.getLog(IgniteIndexedSessionRepository.class);
+
+    /** */
+    private final Ignite ignite;
+
+    /** */
+    private ApplicationEventPublisher eventPublisher = (event) -> {
+    };
+
+    /**
+     * If non-null, this value is used to override
+     * {@link MapSession#setMaxInactiveInterval(Duration)}.
+     */
+    private Integer defaultMaxInactiveInterval;
+
+    /** */
+    private IndexResolver<Session> indexResolver = new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>());
+
+    /** */
+    private String sessionMapName = DEFAULT_SESSION_MAP_NAME;
+
+    /** */
+    private FlushMode flushMode = FlushMode.ON_SAVE;
+
+    /** */
+    private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;
+
+    /** */
+    private IgniteCache<String, IgniteSession> sessions;
+
+    /** */
+    private CacheEntryListenerConfiguration<String, IgniteSession> listenerConfiguration;
+
+    /**
+     * Create a new {@link IgniteIndexedSessionRepository} instance.
+     * @param ignite the {@link Ignite} instance to use for managing sessions
+     */
+    public IgniteIndexedSessionRepository(Ignite ignite) {
+        Assert.notNull(ignite, "Ignite must not be null");
+        this.ignite = ignite;
+    }
+
+    /** */
+    @PostConstruct
+    public void init() {
+        final CacheConfiguration<String, IgniteSession> configuration = new CacheConfiguration<String, IgniteSession>(
+                this.sessionMapName).setIndexedTypes(String.class, IgniteSession.class);
+
+        if (this.defaultMaxInactiveInterval != null)
+            configuration.setExpiryPolicyFactory(TouchedExpiryPolicy
+                    .factoryOf(new javax.cache.expiry.Duration(TimeUnit.SECONDS, this.defaultMaxInactiveInterval)));
+
+        this.sessions = this.ignite.getOrCreateCache(configuration);
+
+        this.listenerConfiguration = new CacheEntryListenerConfiguration<String, IgniteSession>() {
+            @Override public Factory<CacheEntryListener<? super String, ? super IgniteSession>> getCacheEntryListenerFactory() {
+                return (Factory<CacheEntryListener<? super String, ? super IgniteSession>>) () -> IgniteIndexedSessionRepository.this;
+            }
+
+            @Override public boolean isOldValueRequired() {
+                return true;
+            }
+
+            @Override public Factory<CacheEntryEventFilter<? super String, ? super IgniteSession>> getCacheEntryEventFilterFactory() {
+                return null;
+            }
+
+            @Override public boolean isSynchronous() {
+                return false;
+            }
+        };
+        this.sessions.registerCacheEntryListener(this.listenerConfiguration);
+    }
+
+    /** */
+    @PreDestroy
+    public void close() {
+        this.sessions.deregisterCacheEntryListener(this.listenerConfiguration);
+    }
+
+    /**
+     * Sets the {@link ApplicationEventPublisher} that is used to publish
+     * {@link AbstractSessionEvent session events}. The default is to not publish session
+     * events.
+     * @param applicationEventPublisher the {@link ApplicationEventPublisher} that is used
+     * to publish session events. Cannot be null.
+     */
+    public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
+        Assert.notNull(applicationEventPublisher, "ApplicationEventPublisher cannot be null");
+        this.eventPublisher = applicationEventPublisher;
+    }
+
+    /**
+     * Set the maximum inactive interval in seconds between requests before newly created
+     * sessions will be invalidated. A negative time indicates that the session will never
+     * timeout. The default is 1800 (30 minutes).
+     * @param defaultMaxInactiveInterval the maximum inactive interval in seconds
+     */
+    public void setDefaultMaxInactiveInterval(Integer defaultMaxInactiveInterval) {
+        this.defaultMaxInactiveInterval = defaultMaxInactiveInterval;
+    }
+
+    /**
+     * Set the {@link IndexResolver} to use.
+     * @param indexResolver the index resolver
+     */
+    public void setIndexResolver(IndexResolver<Session> indexResolver) {
+        Assert.notNull(indexResolver, "indexResolver cannot be null");
+        this.indexResolver = indexResolver;
+    }
+
+    /**
+     * Set the name of map used to store sessions.
+     * @param sessionMapName the session map name
+     */
+    public void setSessionMapName(String sessionMapName) {
+        Assert.hasText(sessionMapName, "Map name must not be empty");
+        this.sessionMapName = sessionMapName;
+    }
+
+    /**
+     * Sets the flush mode. Default flush mode is {@link FlushMode#ON_SAVE}.
+     * @param flushMode the new flush mode
+     */
+    public void setFlushMode(FlushMode flushMode) {
+        Assert.notNull(flushMode, "flushMode cannot be null");
+        this.flushMode = flushMode;
+    }
+
+    /**
+     * Set the save mode.
+     * @param saveMode the save mode
+     */
+    public void setSaveMode(SaveMode saveMode) {
+        Assert.notNull(saveMode, "saveMode must not be null");
+        this.saveMode = saveMode;
+    }
+
+    /** */
+    @Override public IgniteSession createSession() {
+        MapSession cached = new MapSession();
+        if (this.defaultMaxInactiveInterval != null)
+            cached.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval));
+
+        IgniteSession session = new IgniteSession(cached, true);
+        session.flushImmediateIfNecessary();
+        return session;
+    }
+
+    /** */
+    @Override public void save(IgniteSession session) {
+        if (session.isNew)
+            ttlSessions(session.getMaxInactiveInterval()).put(session.getId(), session);
+
+        else if (session.sessionIdChanged) {
+            this.sessions.remove(session.originalId);
+            session.originalId = session.getId();
+            ttlSessions(session.getMaxInactiveInterval()).put(session.getId(), session);
+        }
+        else if (session.hasChanges()) {
+            if (session.maxInactiveIntervalChanged) {
+                ttlSessions(session.getMaxInactiveInterval()).replace(session.getId(), session);
+            }
+            else {
+                this.sessions.replace(session.getId(), session);
+            }
+        }
+        session.clearChangeFlags();
+    }
+
+    /** */
+    @Override public IgniteSession findById(String id) {
+        IgniteSession saved = this.sessions.get(id);
+        if (saved == null)
+            return null;
+
+        if (saved.isExpired()) {
+            deleteById(saved.getId());
+            return null;
+        }
+        saved.isNew = false;
+        return saved;
+    }
+
+    /** */
+    @Override public void deleteById(String id) {
+        this.sessions.remove(id);
+    }
+
+    /** */
+    @Override public Map<String, IgniteSession> findByIndexNameAndIndexValue(String indexName, String indexValue) {
+        if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName))
+            return Collections.emptyMap();
+
+        final FieldsQueryCursor<List<?>> cursor = this.sessions
+                .query(new SqlFieldsQuery("SELECT * FROM IgniteSession WHERE principal='" + indexValue + "'"));
+
+        if (cursor == null)
+            return Collections.emptyMap();
+
+        final List<List<?>> sessions = cursor.getAll();
+
+        Map<String, IgniteSession> sessionMap = new HashMap<>(sessions.size());
+        sessions.forEach((List<?> res) -> {
+            final MapSession session = (MapSession) res.get(0);
+            final IgniteSession value = new IgniteSession(session, false);
+            value.principal = (String) res.get(1);
+            sessionMap.put(session.getId(), value);
+        });
+
+        return sessionMap;
+    }
+
+    /** */
+    @Override public void onCreated(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+            throws CacheEntryListenerException {
+        events.forEach((event) -> {
+            IgniteSession session = event.getValue();
+            if (session.getId().equals(session.getDelegate().getOriginalId())) {
+                if (logger.isDebugEnabled())
+                    logger.debug("Session created with id: " + session.getId());
+
+                this.eventPublisher.publishEvent(new SessionCreatedEvent(this, session));
+            }
+        });
+    }
+
+    /** */
+    @Override
+    public void onExpired(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+            throws CacheEntryListenerException {
+        events.forEach((event) -> {
+            if (logger.isDebugEnabled())
+                logger.debug("Session expired with id: " + event.getOldValue().getId());
+
+            this.eventPublisher.publishEvent(new SessionExpiredEvent(this, event.getOldValue()));
+        });
+    }
+
+    /** */
+    @Override public void onRemoved(Iterable<CacheEntryEvent<? extends String, ? extends IgniteSession>> events)
+            throws CacheEntryListenerException {
+        events.forEach((event) -> {
+            IgniteSession session = event.getOldValue();
+            if (session != null) {
+                if (logger.isDebugEnabled())
+                    logger.debug("Session deleted with id: " + session.getId());
+
+                this.eventPublisher.publishEvent(new SessionDeletedEvent(this, session));
+            }
+        });
+    }
+
+    /**
+     * Get cache view with custom duration expiry policy.
+     * @param duration expiry duration for IgniteSession.
+     * @return cache with custom duration expiry policy.
+     */
+    private IgniteCache<String, IgniteSession> ttlSessions(Duration duration) {
+        return this.sessions.withExpiryPolicy(createPolicy(duration));
+    }
+
+    /**
+     * Create expiry policy from {@link Duration}.
+     * @param duration expiry duration.
+     * @return expiry policy.
+     */
+    private static TouchedExpiryPolicy createPolicy(Duration duration) {
+        return new TouchedExpiryPolicy(new javax.cache.expiry.Duration(TimeUnit.SECONDS, duration.getSeconds()));
+    }
+
+    /**
+     * A custom implementation of {@link Session} that uses a {@link MapSession} as the
+     * basis for its mapping. It keeps track if changes have been made since last save.
+     */
+    final class IgniteSession implements Session {
+
+        /** */
+        @QuerySqlField
+        private final MapSession delegate;
+
+        /** */
+        @GridDirectTransient
+        private boolean isNew;
+
+        /** */
+        @GridDirectTransient
+        private boolean sessionIdChanged;
+
+        /** */
+        @GridDirectTransient
+        private boolean lastAccessedTimeChanged;
+
+        /** */
+        @GridDirectTransient
+        private boolean maxInactiveIntervalChanged;
+
+        /** */
+        @GridDirectTransient
+        private String originalId;
+
+        /** */
+        @GridDirectTransient
+        private Map<String, Object> delta = new HashMap<>();
+
+        /** */
+        @QuerySqlField(index = true)
+        private String principal;
+
+        IgniteSession(MapSession cached, boolean isNew) {
+            this.delegate = cached;
+            this.isNew = isNew;
+            this.originalId = cached.getId();
+            if (this.isNew || (IgniteIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS))
+                getAttributeNames()
+                        .forEach((attributeName) -> this.delta.put(attributeName, cached.getAttribute(attributeName)));
+
+        }
+
+        /** */
+        @Override public void setLastAccessedTime(Instant lastAccessedTime) {
+            this.delegate.setLastAccessedTime(lastAccessedTime);
+            this.lastAccessedTimeChanged = true;
+            flushImmediateIfNecessary();
+        }
+
+        /** */
+        @Override public boolean isExpired() {
+            return this.delegate.isExpired();
+        }
+
+        /** */
+        @Override public Instant getCreationTime() {
+            return this.delegate.getCreationTime();
+        }
+
+        /** */
+        @Override public String getId() {
+            return this.delegate.getId();
+        }
+
+        /** */
+        @Override public String changeSessionId() {
+            String newSessionId = this.delegate.changeSessionId();
+            this.sessionIdChanged = true;
+            return newSessionId;
+        }
+
+        /** */
+        @Override public Instant getLastAccessedTime() {
+            return this.delegate.getLastAccessedTime();
+        }
+
+        /** */
+        @Override public void setMaxInactiveInterval(Duration interval) {
+            this.delegate.setMaxInactiveInterval(interval);
+            this.maxInactiveIntervalChanged = true;
+            flushImmediateIfNecessary();
+        }
+
+        /** */
+        @Override public Duration getMaxInactiveInterval() {
+            return this.delegate.getMaxInactiveInterval();
+        }
+
+        /** */
+        @Override public <T> T getAttribute(String attributeName) {
+            T attributeValue = this.delegate.getAttribute(attributeName);
+            if (attributeValue != null
+                    && IgniteIndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE))
+                this.delta.put(attributeName, attributeValue);
+
+            return attributeValue;
+        }
+
+        /** */
+        @Override public Set<String> getAttributeNames() {
+            return this.delegate.getAttributeNames();
+        }
+
+        /** */
+        @Override public void setAttribute(String attributeName, Object attributeValue) {
+            this.delegate.setAttribute(attributeName, attributeValue);
+            this.delta.put(attributeName, attributeValue);
+            if (SPRING_SECURITY_CONTEXT.equals(attributeName)) {
+                Map<String, String> indexes = IgniteIndexedSessionRepository.this.indexResolver.resolveIndexesFor(this);
+                String principal = (attributeValue != null) ? indexes.get(PRINCIPAL_NAME_INDEX_NAME) : null;
+                this.delegate.setAttribute(PRINCIPAL_NAME_INDEX_NAME, principal);
+                this.principal = principal;
+            }
+            flushImmediateIfNecessary();
+        }
+
+        /** */
+        @Override public void removeAttribute(String attributeName) {
+            setAttribute(attributeName, null);
+        }
+
+        /** */
+        MapSession getDelegate() {
+            return this.delegate;
+        }
+
+        /** */
+        boolean hasChanges() {
+            return (this.lastAccessedTimeChanged || this.maxInactiveIntervalChanged || !this.delta.isEmpty());
+        }
+
+        /** */
+        void clearChangeFlags() {
+            this.isNew = false;
+            this.lastAccessedTimeChanged = false;
+            this.sessionIdChanged = false;
+            this.maxInactiveIntervalChanged = false;
+            this.delta.clear();
+        }
+
+        /** */
+        private void flushImmediateIfNecessary() {
+            if (IgniteIndexedSessionRepository.this.flushMode == FlushMode.IMMEDIATE)
+                IgniteIndexedSessionRepository.this.save(this);
+        }
+
+        /** */
+        @Override public boolean equals(Object o) {
+            if (this == o)
+                return true;
+
+            if (o == null || getClass() != o.getClass())
+                return false;
+
+            IgniteSession session = (IgniteSession) o;
+            return this.delegate.equals(session.delegate);
+        }
+
+        /** */
+        @Override public int hashCode() {
+            return Objects.hash(this.delegate);
+        }
+    }
+}
diff --git a/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java
new file mode 100644
index 0000000..82b51a4
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/org/apache/ignite/spring/sessions/SpringSessionIgnite.java
@@ -0,0 +1,39 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+import org.apache.ignite.Ignite;
+import org.springframework.beans.factory.annotation.Qualifier;
+
+/**
+ * Qualifier annotation for a {@link Ignite} to be injected in
+ * {@link IgniteIndexedSessionRepository}.
+ *
+ */
+@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE })
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+@Qualifier
+public @interface SpringSessionIgnite {
+}
diff --git a/modules/spring-session-ext/src/main/java/package-info.java b/modules/spring-session-ext/src/main/java/package-info.java
new file mode 100644
index 0000000..8c21795
--- /dev/null
+++ b/modules/spring-session-ext/src/main/java/package-info.java
@@ -0,0 +1,5 @@
+/**
+ * <!-- Package description. -->
+ * Contains classes that allow using Ignite as backing store for storing Spring sessions.
+ */
+package org.apache.ignite.spring.sessions;
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java
new file mode 100644
index 0000000..7a6036a
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/AbstractIgniteIndexedSessionRepositoryITest.java
@@ -0,0 +1,242 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.time.Duration;
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.junit.jupiter.api.Test;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.session.FindByIndexNameSessionRepository;
+
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepository.IgniteSession;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Base class for {@link IgniteIndexedSessionRepository} integration tests.
+ */
+abstract class AbstractIgniteIndexedSessionRepositoryITest {
+    /** */
+    private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+    /** */
+    @Autowired
+    private Ignite ignite;
+
+    /** */
+    @Autowired
+    private IgniteIndexedSessionRepository repository;
+
+    /** */
+    @Test
+    void createAndDestroySession() {
+        IgniteIndexedSessionRepository.IgniteSession sessionToSave = this.repository.createSession();
+        String sessionId = sessionToSave.getId();
+
+        IgniteCache<String, IgniteIndexedSessionRepository.IgniteSession> cache = this.ignite
+                .getOrCreateCache(IgniteIndexedSessionRepository.DEFAULT_SESSION_MAP_NAME);
+
+        assertThat(cache.size()).isEqualTo(0);
+
+        this.repository.save(sessionToSave);
+
+        assertThat(cache.size()).isEqualTo(1);
+        assertThat(cache.get(sessionId)).isEqualTo(sessionToSave);
+
+        this.repository.deleteById(sessionId);
+
+        assertThat(cache.size()).isEqualTo(0);
+    }
+
+    /** */
+    @Test
+    void changeSessionIdWhenOnlyChangeId() {
+        String attrName = "changeSessionId";
+        String attrValue = "changeSessionId-value";
+        IgniteSession toSave = this.repository.createSession();
+        toSave.setAttribute(attrName, attrValue);
+
+        this.repository.save(toSave);
+
+        IgniteSession findById = this.repository.findById(toSave.getId());
+
+        assertThat(findById.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+        String originalFindById = findById.getId();
+        String changeSessionId = findById.changeSessionId();
+
+        this.repository.save(findById);
+
+        assertThat(this.repository.findById(originalFindById)).isNull();
+
+        IgniteSession findByChangeSessionId = this.repository.findById(changeSessionId);
+
+        assertThat(findByChangeSessionId.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+        this.repository.deleteById(changeSessionId);
+    }
+
+    /** */
+    @Test
+    void changeSessionIdWhenChangeTwice() {
+        IgniteSession toSave = this.repository.createSession();
+
+        this.repository.save(toSave);
+
+        String originalId = toSave.getId();
+        String changeId1 = toSave.changeSessionId();
+        String changeId2 = toSave.changeSessionId();
+
+        this.repository.save(toSave);
+
+        assertThat(this.repository.findById(originalId)).isNull();
+        assertThat(this.repository.findById(changeId1)).isNull();
+        assertThat(this.repository.findById(changeId2)).isNotNull();
+
+        this.repository.deleteById(changeId2);
+    }
+
+    /** */
+    @Test
+    void changeSessionIdWhenSetAttributeOnChangedSession() {
+        String attrName = "changeSessionId";
+        String attrValue = "changeSessionId-value";
+
+        IgniteSession toSave = this.repository.createSession();
+
+        this.repository.save(toSave);
+
+        IgniteSession findById = this.repository.findById(toSave.getId());
+
+        findById.setAttribute(attrName, attrValue);
+
+        String originalFindById = findById.getId();
+        String changeSessionId = findById.changeSessionId();
+
+        this.repository.save(findById);
+
+        assertThat(this.repository.findById(originalFindById)).isNull();
+
+        IgniteSession findByChangeSessionId = this.repository.findById(changeSessionId);
+
+        assertThat(findByChangeSessionId.<String>getAttribute(attrName)).isEqualTo(attrValue);
+
+        this.repository.deleteById(changeSessionId);
+    }
+
+    /** */
+    @Test
+    void changeSessionIdWhenHasNotSaved() {
+        IgniteSession toSave = this.repository.createSession();
+        String originalId = toSave.getId();
+        toSave.changeSessionId();
+
+        this.repository.save(toSave);
+
+        assertThat(this.repository.findById(toSave.getId())).isNotNull();
+        assertThat(this.repository.findById(originalId)).isNull();
+
+        this.repository.deleteById(toSave.getId());
+    }
+
+    @Test
+    void attemptToUpdateSessionAfterDelete() {
+        IgniteSession session = this.repository.createSession();
+        String sessionId = session.getId();
+        this.repository.save(session);
+        session = this.repository.findById(sessionId);
+        session.setAttribute("attributeName", "attributeValue");
+        this.repository.deleteById(sessionId);
+        this.repository.save(session);
+
+        assertThat(this.repository.findById(sessionId)).isNull();
+    }
+
+    /** */
+    @Test
+    void expireSession() {
+        IgniteSession session = this.repository.createSession();
+        String sessionId = session.getId();
+
+        session.setMaxInactiveInterval(Duration.ofNanos(0));
+
+        this.repository.save(session);
+        assertThat(this.repository.findById(sessionId)).isNull();
+    }
+
+    /** */
+    @Test
+    void createAndUpdateSession() {
+        IgniteSession session = this.repository.createSession();
+        String sessionId = session.getId();
+
+        this.repository.save(session);
+
+        session = this.repository.findById(sessionId);
+        session.setAttribute("attributeName", "attributeValue");
+
+        this.repository.save(session);
+
+        assertThat(this.repository.findById(sessionId)).isNotNull();
+    }
+
+    /** */
+    @Test
+    void createSessionWithSecurityContextAndFindById() {
+        IgniteSession session = this.repository.createSession();
+        String sessionId = session.getId();
+
+        Authentication authentication = new UsernamePasswordAuthenticationToken("saves-" + System.currentTimeMillis(),
+                "password", AuthorityUtils.createAuthorityList("ROLE_USER"));
+        SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+        securityContext.setAuthentication(authentication);
+        session.setAttribute(SPRING_SECURITY_CONTEXT, securityContext);
+
+        this.repository.save(session);
+
+        assertThat(this.repository.findById(sessionId)).isNotNull();
+    }
+
+    /** */
+    @Test
+    void createSessionWithSecurityContextAndFindByPrincipal() {
+        IgniteSession session = this.repository.createSession();
+
+        String username = "saves-" + System.currentTimeMillis();
+        Authentication authentication = new UsernamePasswordAuthenticationToken(username, "password",
+                AuthorityUtils.createAuthorityList("ROLE_USER"));
+        SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
+        securityContext.setAuthentication(authentication);
+        session.setAttribute(SPRING_SECURITY_CONTEXT, securityContext);
+
+        this.repository.save(session);
+
+        assertThat(this.repository
+                .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, username))
+                .hasSize(1);
+    }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java
new file mode 100644
index 0000000..e40fc72
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/EmbeddedIgniteIndexedSessionRepositoryITest.java
@@ -0,0 +1,65 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.Ignition;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.extension.ExtendWith;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import org.springframework.test.context.web.WebAppConfiguration;
+
+/**
+ * Integration tests for {@link IgniteIndexedSessionRepository} using embedded topology.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration
+@WebAppConfiguration
+public
+class EmbeddedIgniteIndexedSessionRepositoryITest extends AbstractIgniteIndexedSessionRepositoryITest {
+    /** */
+    @BeforeAll
+    static void setUpClass() {
+        Ignition.stopAll(true);
+    }
+
+    /** */
+    @AfterAll
+    static void tearDownClass() {
+        Ignition.stopAll(true);
+    }
+
+    /** */
+    @EnableIgniteHttpSession
+    @Configuration
+    static class IgniteSessionConfig {
+
+        /** */
+        @Bean
+        Ignite ignite() {
+            return IgniteITestUtils.embeddedIgniteServer();
+        }
+
+    }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java
new file mode 100644
index 0000000..0e69f4f
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteHttpSessionConfigurationTest.java
@@ -0,0 +1,495 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+
+import org.springframework.beans.factory.BeanCreationException;
+import org.springframework.context.annotation.AnnotationConfigApplicationContext;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.Primary;
+import org.springframework.core.annotation.Order;
+import org.springframework.session.FlushMode;
+import org.springframework.session.IndexResolver;
+import org.springframework.session.SaveMode;
+import org.springframework.session.Session;
+import org.springframework.session.config.SessionRepositoryCustomizer;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+
+/**
+ * Tests for {@link IgniteHttpSessionConfiguration}.
+ */
+public class IgniteHttpSessionConfigurationTest {
+    /** */
+    private static final String MAP_NAME = "spring:test:sessions";
+
+    /** */
+    private static final int MAX_INACTIVE_INTERVAL_IN_SECONDS = 600;
+
+    /** */
+    private AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext();
+
+    /** */
+    @AfterEach
+    void closeContext() {
+        if (this.context != null)
+            this.context.close();
+    }
+
+    /** */
+    @Test
+    void noIgniteConfiguration() {
+        assertThatExceptionOfType(BeanCreationException.class)
+                .isThrownBy(() -> registerAndRefresh(NoIgniteConfiguration.class)).withMessageContaining("Ignite");
+    }
+
+    /** */
+    @Test
+    void defaultConfiguration() {
+        registerAndRefresh(DefaultConfiguration.class);
+
+        assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).isNotNull();
+    }
+
+    /** */
+    @Test
+    void customTableName() {
+        registerAndRefresh(CustomSessionMapNameConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        IgniteHttpSessionConfiguration configuration = this.context.getBean(IgniteHttpSessionConfiguration.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME);
+    }
+
+    /** */
+    @Test
+    void setCustomSessionMapName() {
+        registerAndRefresh(BaseConfiguration.class, CustomSessionMapNameSetConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        IgniteHttpSessionConfiguration configuration = this.context.getBean(IgniteHttpSessionConfiguration.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(configuration, "sessionMapName")).isEqualTo(MAP_NAME);
+    }
+
+    /** */
+    @Test
+    void setCustomMaxInactiveIntervalInSeconds() {
+        registerAndRefresh(BaseConfiguration.class, CustomMaxInactiveIntervalInSecondsSetConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval"))
+                .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+    }
+
+    /** */
+    @Test
+    void customMaxInactiveIntervalInSeconds() {
+        registerAndRefresh(CustomMaxInactiveIntervalInSecondsConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "defaultMaxInactiveInterval"))
+                .isEqualTo(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+    }
+
+    /** */
+    @Test
+    void customFlushImmediately() {
+        registerAndRefresh(CustomFlushImmediatelyConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "flushMode")).isEqualTo(FlushMode.IMMEDIATE);
+    }
+
+    /** */
+    @Test
+    void setCustomFlushImmediately() {
+        registerAndRefresh(BaseConfiguration.class, CustomFlushImmediatelySetConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        assertThat(repository).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "flushMode")).isEqualTo(FlushMode.IMMEDIATE);
+    }
+
+    /** */
+    @Test
+    void customSaveModeAnnotation() {
+        registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionAnnotationConfiguration.class);
+        assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).hasFieldOrPropertyWithValue("saveMode",
+                SaveMode.ALWAYS);
+    }
+
+    /** */
+    @Test
+    void customSaveModeSetter() {
+        registerAndRefresh(BaseConfiguration.class, CustomSaveModeExpressionSetterConfiguration.class);
+        assertThat(this.context.getBean(IgniteIndexedSessionRepository.class)).hasFieldOrPropertyWithValue("saveMode",
+                SaveMode.ALWAYS);
+    }
+
+    /** */
+    @Test
+    void qualifiedIgniteConfiguration() {
+        registerAndRefresh(QualifiedIgniteConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        Ignite ignite = this.context.getBean("qualifiedIgnite", Ignite.class);
+        assertThat(repository).isNotNull();
+        assertThat(ignite).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+                .isEqualTo(QualifiedIgniteConfiguration.qualifiedIgniteSessions);
+    }
+
+    /** */
+    @Test
+    void primaryIgniteConfiguration() {
+        registerAndRefresh(PrimaryIgniteConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        Ignite ignite = this.context.getBean("primaryIgnite", Ignite.class);
+        assertThat(repository).isNotNull();
+        assertThat(ignite).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+                .isEqualTo(PrimaryIgniteConfiguration.primaryIgniteSessions);
+    }
+
+    /** */
+    @Test
+    void qualifiedAndPrimaryIgniteConfiguration() {
+        registerAndRefresh(QualifiedAndPrimaryIgniteConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        Ignite ignite = this.context.getBean("qualifiedIgnite", Ignite.class);
+        assertThat(repository).isNotNull();
+        assertThat(ignite).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+                .isEqualTo(QualifiedAndPrimaryIgniteConfiguration.qualifiedIgniteSessions);
+    }
+
+    /** */
+    @Test
+    void namedIgniteConfiguration() {
+        registerAndRefresh(NamedIgniteConfiguration.class);
+
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        Ignite ignite = this.context.getBean("ignite", Ignite.class);
+        assertThat(repository).isNotNull();
+        assertThat(ignite).isNotNull();
+        assertThat(ReflectionTestUtils.getField(repository, "sessions"))
+                .isEqualTo(NamedIgniteConfiguration.igniteSessions);
+    }
+
+    /** */
+    @Test
+    void multipleIgniteConfiguration() {
+        assertThatExceptionOfType(BeanCreationException.class)
+                .isThrownBy(() -> registerAndRefresh(MultipleIgniteConfiguration.class))
+                .withMessageContaining("expected single matching bean but found 2");
+    }
+
+    /** */
+    @Test
+    void customIndexResolverConfiguration() {
+        registerAndRefresh(CustomIndexResolverConfiguration.class);
+        IgniteIndexedSessionRepository repository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        @SuppressWarnings("unchecked")
+        IndexResolver<Session> indexResolver = this.context.getBean(IndexResolver.class);
+        assertThat(repository).isNotNull();
+        assertThat(indexResolver).isNotNull();
+        assertThat(repository).hasFieldOrPropertyWithValue("indexResolver", indexResolver);
+    }
+
+    /** */
+    @Test
+    void sessionRepositoryCustomizer() {
+        registerAndRefresh(SessionRepositoryCustomizerConfiguration.class);
+        IgniteIndexedSessionRepository sessionRepository = this.context.getBean(IgniteIndexedSessionRepository.class);
+        assertThat(sessionRepository).hasFieldOrPropertyWithValue("defaultMaxInactiveInterval",
+                MAX_INACTIVE_INTERVAL_IN_SECONDS);
+    }
+
+    /** */
+    private void registerAndRefresh(Class<?>... annotatedClasses) {
+        this.context.register(annotatedClasses);
+        this.context.refresh();
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class NoIgniteConfiguration {
+
+    }
+
+    /** */
+    static class BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> defaultIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        Ignite defaultIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(defaultIgniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class DefaultConfiguration extends BaseConfiguration {
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession(sessionMapName = MAP_NAME)
+    static class CustomSessionMapNameConfiguration extends BaseConfiguration {
+
+    }
+
+    /** */
+    @Configuration
+    static class CustomSessionMapNameSetConfiguration extends IgniteHttpSessionConfiguration {
+
+        CustomSessionMapNameSetConfiguration() {
+            setSessionMapName(MAP_NAME);
+        }
+
+    }
+
+    /** */
+    @Configuration
+    static class CustomMaxInactiveIntervalInSecondsSetConfiguration extends IgniteHttpSessionConfiguration {
+
+        CustomMaxInactiveIntervalInSecondsSetConfiguration() {
+            setMaxInactiveIntervalInSeconds(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession(maxInactiveIntervalInSeconds = MAX_INACTIVE_INTERVAL_IN_SECONDS)
+    static class CustomMaxInactiveIntervalInSecondsConfiguration extends BaseConfiguration {
+
+    }
+
+    /** */
+    @Configuration
+    static class CustomFlushImmediatelySetConfiguration extends IgniteHttpSessionConfiguration {
+
+        CustomFlushImmediatelySetConfiguration() {
+            setFlushMode(FlushMode.IMMEDIATE);
+        }
+
+    }
+
+    /** */
+    @EnableIgniteHttpSession(saveMode = SaveMode.ALWAYS)
+    static class CustomSaveModeExpressionAnnotationConfiguration {
+
+    }
+
+    /** */
+    @Configuration
+    static class CustomSaveModeExpressionSetterConfiguration extends IgniteHttpSessionConfiguration {
+
+        CustomSaveModeExpressionSetterConfiguration() {
+            setSaveMode(SaveMode.ALWAYS);
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession(flushMode = FlushMode.IMMEDIATE)
+    static class CustomFlushImmediatelyConfiguration extends BaseConfiguration {
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class QualifiedIgniteConfiguration extends BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> qualifiedIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        @SpringSessionIgnite
+        Ignite qualifiedIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(qualifiedIgniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class PrimaryIgniteConfiguration extends BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> primaryIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        @Primary
+        Ignite primaryIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(primaryIgniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class QualifiedAndPrimaryIgniteConfiguration extends BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> qualifiedIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> primaryIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        @SpringSessionIgnite
+        Ignite qualifiedIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(qualifiedIgniteSessions);
+            return ignite;
+        }
+
+        /** */
+        @Bean
+        @Primary
+        Ignite primaryIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(primaryIgniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class NamedIgniteConfiguration extends BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> igniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        Ignite ignite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(igniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @Configuration
+    @EnableIgniteHttpSession
+    static class MultipleIgniteConfiguration extends BaseConfiguration {
+
+        /** */
+        @SuppressWarnings("unchecked")
+        static IgniteCache<Object, Object> secondaryIgniteSessions = mock(IgniteCache.class);
+
+        /** */
+        @Bean
+        Ignite secondaryIgnite() {
+            Ignite ignite = mock(Ignite.class);
+            given(ignite.getOrCreateCache(ArgumentMatchers.<CacheConfiguration<Object, Object>>any()))
+                    .willReturn(secondaryIgniteSessions);
+            return ignite;
+        }
+
+    }
+
+    /** */
+    @EnableIgniteHttpSession
+    static class CustomIndexResolverConfiguration extends BaseConfiguration {
+
+        /** */
+        @Bean
+        @SuppressWarnings("unchecked")
+        IndexResolver<Session> indexResolver() {
+            return mock(IndexResolver.class);
+        }
+
+    }
+
+    /** */
+    @EnableIgniteHttpSession
+    static class SessionRepositoryCustomizerConfiguration extends BaseConfiguration {
+
+        /** */
+        @Bean
+        @Order(0)
+        SessionRepositoryCustomizer<IgniteIndexedSessionRepository> sessionRepositoryCustomizerOne() {
+            return (sessionRepository) -> sessionRepository.setDefaultMaxInactiveInterval(0);
+        }
+
+        /** */
+        @Bean
+        @Order(1)
+        SessionRepositoryCustomizer<IgniteIndexedSessionRepository> sessionRepositoryCustomizerTwo() {
+            return (sessionRepository) -> sessionRepository
+                    .setDefaultMaxInactiveInterval(MAX_INACTIVE_INTERVAL_IN_SECONDS);
+        }
+    }
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java
new file mode 100644
index 0000000..f0b2a94
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteITestUtils.java
@@ -0,0 +1,37 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.Ignition;
+
+/**
+ * Utility class for Ignite integration tests.
+ */
+final class IgniteITestUtils {
+    private IgniteITestUtils() {
+    }
+
+    /**
+     * Creates {@link Ignite} for use in integration tests.
+     * @return the Ignite instance
+     */
+    static Ignite embeddedIgniteServer() {
+        return Ignition.start();
+    }
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java
new file mode 100644
index 0000000..61f4eb8
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/spring/sessions/IgniteIndexedSessionRepositoryTest.java
@@ -0,0 +1,483 @@
+package org.apache.ignite.spring.sessions;
+
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+
+import javax.cache.expiry.TouchedExpiryPolicy;
+
+import org.apache.ignite.Ignite;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.cache.query.FieldsQueryCursor;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.jetbrains.annotations.NotNull;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentMatchers;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.authority.AuthorityUtils;
+import org.springframework.session.FindByIndexNameSessionRepository;
+import org.springframework.session.FlushMode;
+import org.springframework.session.MapSession;
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepository.IgniteSession;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.BDDMockito.given;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoMoreInteractions;
+
+/**
+ * Tests for {@link IgniteIndexedSessionRepository}.
+ */
+public class IgniteIndexedSessionRepositoryTest {
+    /** */
+    private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";
+
+    /** */
+    private final Ignite ignite = mock(Ignite.class);
+
+    /** */
+    @SuppressWarnings("unchecked")
+    private final IgniteCache<String, IgniteSession> sessions = mock(IgniteCache.class);
+
+    /** */
+    private IgniteIndexedSessionRepository repository;
+
+    /** */
+    @BeforeEach
+    void setUp() {
+        given(this.ignite.<String, IgniteSession>getOrCreateCache(
+                ArgumentMatchers.<CacheConfiguration<String, IgniteSession>>any())).willReturn(this.sessions);
+        given(this.sessions.withExpiryPolicy(ArgumentMatchers.any())).willReturn(this.sessions);
+        this.repository = new IgniteIndexedSessionRepository(this.ignite);
+        this.repository.init();
+    }
+
+    /** */
+    @Test
+    void constructorNullIgnite() {
+        assertThatIllegalArgumentException().isThrownBy(() -> new IgniteIndexedSessionRepository(null))
+                .withMessage("Ignite must not be null");
+    }
+
+    /** */
+    @Test
+    void setSaveModeNull() {
+        assertThatIllegalArgumentException().isThrownBy(() -> this.repository.setSaveMode(null))
+                .withMessage("saveMode must not be null");
+    }
+
+    /** */
+    @Test
+    void createSessionDefaultMaxInactiveInterval() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+
+        assertThat(session.getMaxInactiveInterval()).isEqualTo(new MapSession().getMaxInactiveInterval());
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void createSessionCustomMaxInactiveInterval() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        int interval = 1;
+        this.repository.setDefaultMaxInactiveInterval(interval);
+
+        IgniteSession session = this.repository.createSession();
+
+        assertThat(session.getMaxInactiveInterval()).isEqualTo(Duration.ofSeconds(interval));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveNewFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        verifyNoMoreInteractions(this.sessions);
+
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveNewFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUpdatedAttributeFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        session.setAttribute("testName", "testValue");
+        verifyNoMoreInteractions(this.sessions);
+
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    @Test
+    void saveUpdatedAttributeFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        session.setAttribute("testName", "testValue");
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void removeAttributeFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        session.removeAttribute("testName");
+        verifyNoMoreInteractions(this.sessions);
+
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void removeAttributeFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        session.removeAttribute("testName");
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUpdatedLastAccessedTimeFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        session.setLastAccessedTime(Instant.now());
+        verifyNoMoreInteractions(this.sessions);
+
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUpdatedLastAccessedTimeFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        session.setLastAccessedTime(Instant.now());
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).replace(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUpdatedMaxInactiveIntervalInSecondsFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        session.setMaxInactiveInterval(Duration.ofSeconds(1));
+        verifyNoMoreInteractions(this.sessions);
+
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUpdatedMaxInactiveIntervalInSecondsFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+        String sessionId = session.getId();
+        session.setMaxInactiveInterval(Duration.ofSeconds(1));
+        verify(this.sessions, times(1)).put(eq(sessionId), eq(session));
+        verify(this.sessions, times(1)).replace(eq(sessionId), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUnchangedFlushModeOnSave() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession session = this.repository.createSession();
+        this.repository.save(session);
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void saveUnchangedFlushModeImmediate() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        this.repository.setFlushMode(FlushMode.IMMEDIATE);
+
+        IgniteSession session = this.repository.createSession();
+        verify(this.sessions, times(1)).put(eq(session.getId()), eq(session));
+        verify(this.sessions, times(1)).withExpiryPolicy(eq(createExpiryPolicy(session)));
+
+        this.repository.save(session);
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void getSessionNotFound() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        String sessionId = "testSessionId";
+
+        IgniteSession session = this.repository.findById(sessionId);
+
+        assertThat(session).isNull();
+        verify(this.sessions, times(1)).get(eq(sessionId));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void getSessionExpired() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession expired = this.repository.new IgniteSession(new MapSession(), true);
+
+        expired.setLastAccessedTime(Instant.now().minusSeconds(MapSession.DEFAULT_MAX_INACTIVE_INTERVAL_SECONDS + 1));
+        given(this.sessions.get(eq(expired.getId()))).willReturn(expired);
+
+        IgniteSession session = this.repository.findById(expired.getId());
+
+        assertThat(session).isNull();
+        verify(this.sessions, times(1)).get(eq(expired.getId()));
+        verify(this.sessions, times(1)).remove(eq(expired.getId()));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void getSessionFound() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        IgniteSession saved = this.repository.new IgniteSession(new MapSession(), true);
+        saved.setAttribute("savedName", "savedValue");
+        given(this.sessions.get(eq(saved.getId()))).willReturn(saved);
+
+        IgniteSession session = this.repository.findById(saved.getId());
+
+        assertThat(session.getId()).isEqualTo(saved.getId());
+        assertThat(session.<String>getAttribute("savedName")).isEqualTo("savedValue");
+        verify(this.sessions, times(1)).get(eq(saved.getId()));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void delete() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        String sessionId = "testSessionId";
+
+        this.repository.deleteById(sessionId);
+
+        verify(this.sessions, times(1)).remove(eq(sessionId));
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void findByIndexNameAndIndexValueUnknownIndexName() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        String indexValue = "testIndexValue";
+
+        Map<String, IgniteSession> sessions = this.repository.findByIndexNameAndIndexValue("testIndexName", indexValue);
+
+        assertThat(sessions).isEmpty();
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void findByIndexNameAndIndexValuePrincipalIndexNameNotFound() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        String principal = "username";
+
+        Map<String, IgniteSession> sessions = this.repository
+                .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal);
+
+        verify(this.sessions, times(1)).query(ArgumentMatchers
+                .argThat((argument) -> ("SELECT * FROM IgniteSession WHERE principal='" + principal + "'")
+                        .equals(argument.getSql())));
+
+        assertThat(sessions).isEmpty();
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void findByIndexNameAndIndexValuePrincipalIndexNameFound() {
+        verify(this.sessions, times(1)).registerCacheEntryListener(ArgumentMatchers.any());
+
+        String principal = "username";
+        Authentication authentication = new UsernamePasswordAuthenticationToken(principal, "notused",
+                AuthorityUtils.createAuthorityList("ROLE_USER"));
+
+        List<Object> saved = new ArrayList<>(2);
+
+        final MapSession ses1 = new MapSession();
+        ses1.setAttribute(SPRING_SECURITY_CONTEXT, authentication);
+        IgniteSession saved1 = this.repository.new IgniteSession(ses1, true);
+        saved.add(Arrays.asList(ses1, authentication.getPrincipal()));
+        final MapSession ses2 = new MapSession();
+        ses2.setAttribute(SPRING_SECURITY_CONTEXT, authentication);
+        IgniteSession saved2 = this.repository.new IgniteSession(ses2, true);
+        saved.add(Arrays.asList(ses2, authentication.getPrincipal()));
+
+        given(this.sessions.query(ArgumentMatchers.any())).willReturn(new FieldsQueryCursor<List<?>>() {
+            /** */
+            @Override
+            public String getFieldName(int idx) {
+                return null;
+            }
+
+            /** */
+            @Override
+            public int getColumnsCount() {
+                return 2;
+            }
+
+            /** */
+            @Override
+            public List<List<?>> getAll() {
+                return (List) saved;
+            }
+
+            /** */
+            @Override
+            public void close() {
+
+            }
+
+            /** */
+            @NotNull
+            @Override
+            public Iterator<List<?>> iterator() {
+                return (Iterator) saved.iterator();
+            }
+        });
+
+        Map<String, IgniteSession> sessions = this.repository
+                .findByIndexNameAndIndexValue(FindByIndexNameSessionRepository.PRINCIPAL_NAME_INDEX_NAME, principal);
+
+        assertThat(sessions).hasSize(2);
+        verify(this.sessions, times(1)).query(any());
+        verifyNoMoreInteractions(this.sessions);
+    }
+
+    /** */
+    @Test
+    void getAttributeNamesAndRemove() {
+        IgniteSession session = this.repository.createSession();
+        session.setAttribute("attribute1", "value1");
+        session.setAttribute("attribute2", "value2");
+
+        for (String attributeName : session.getAttributeNames()) {
+            session.removeAttribute(attributeName);
+        }
+
+        assertThat(session.getAttributeNames()).isEmpty();
+    }
+
+    /** */
+    private static TouchedExpiryPolicy createExpiryPolicy(IgniteSession session) {
+        return new TouchedExpiryPolicy(
+                new javax.cache.expiry.Duration(TimeUnit.SECONDS, session.getMaxInactiveInterval().getSeconds()));
+    }
+
+}
diff --git a/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java b/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java
new file mode 100644
index 0000000..880119e
--- /dev/null
+++ b/modules/spring-session-ext/src/test/java/org/apache/ignite/testsuites/IgniteSpringSessionTestSuite.java
@@ -0,0 +1,19 @@
+package org.apache.ignite.testsuites;
+
+import org.apache.ignite.spring.sessions.EmbeddedIgniteIndexedSessionRepositoryITest;
+import org.apache.ignite.spring.sessions.IgniteHttpSessionConfigurationTest;
+import org.apache.ignite.spring.sessions.IgniteIndexedSessionRepositoryTest;
+import org.junit.runner.RunWith;
+import org.junit.runners.Suite;
+
+/**
+ * Ignite Spring Session Test Suite
+ */
+@RunWith(Suite.class)
+@Suite.SuiteClasses({
+        EmbeddedIgniteIndexedSessionRepositoryITest.class,
+        IgniteHttpSessionConfigurationTest.class,
+        IgniteIndexedSessionRepositoryTest.class
+})
+public class IgniteSpringSessionTestSuite {
+}
diff --git a/pom.xml b/pom.xml
index 8682549..27249de 100644
--- a/pom.xml
+++ b/pom.xml
@@ -62,6 +62,7 @@
         <module>modules/performance-statistics-ext</module>
         <module>modules/spring-tx-ext</module>
         <module>modules/spring-cache-ext</module>
+        <module>modules/spring-session-ext</module>
     </modules>
 
     <profiles>