KNOX-2377 - Address potential loss of token state (#345)

diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
index 4f2e18c..24d3fb9 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateService.java
@@ -21,7 +21,11 @@
 import org.apache.knox.gateway.services.security.AliasService;
 import org.apache.knox.gateway.services.security.AliasServiceException;
 import org.apache.knox.gateway.services.security.token.UnknownTokenException;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.state.TokenStateJournalFactory;
 
+import java.io.IOException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -49,6 +53,8 @@
 
   private final List<TokenState> unpersistedState = new ArrayList<>();
 
+  private TokenStateJournal journal;
+
   public void setAliasService(AliasService aliasService) {
     this.aliasService = aliasService;
   }
@@ -59,6 +65,32 @@
     if (aliasService == null) {
       throw new ServiceLifecycleException("The required AliasService reference has not been set.");
     }
+
+    try {
+      // Initialize the token state journal
+      journal = TokenStateJournalFactory.create(config);
+
+      // Load any persisted journal entries, and add them to the unpersisted state collection
+      List<JournalEntry> entries = journal.get();
+      for (JournalEntry entry : entries) {
+        String id = entry.getTokenId();
+        long issueTime = Long.parseLong(entry.getIssueTime());
+        long expiration = Long.parseLong(entry.getExpiration());
+        long maxLifetime = Long.parseLong(entry.getMaxLifetime());
+
+        // Add the token state to memory
+        super.addToken(id, issueTime, expiration, maxLifetime);
+
+        synchronized (unpersistedState) {
+          // The max lifetime entry is added by way of the call to super.addToken(),
+          // so only need to add the expiration entry here.
+          unpersistedState.add(new TokenExpiration(id, expiration));
+        }
+      }
+    } catch (IOException e) {
+      throw new ServiceLifecycleException("Failed to load persisted state from the token state journal", e);
+    }
+
     statePersistenceInterval = config.getKnoxTokenStateAliasPersistenceInterval();
     if (statePersistenceInterval > 0) {
       statePersistenceScheduler = Executors.newScheduledThreadPool(1);
@@ -69,7 +101,7 @@
   public void start() throws ServiceLifecycleException {
     super.start();
     if (statePersistenceScheduler != null) {
-      // Run token eviction task at configured interval
+      // Run token persistence task at configured interval
       statePersistenceScheduler.scheduleAtFixedRate(this::persistTokenState,
                                                     statePersistenceInterval,
                                                     statePersistenceInterval,
@@ -83,6 +115,9 @@
     if (statePersistenceScheduler != null) {
       statePersistenceScheduler.shutdown();
     }
+
+    // Make an attempt to persist any unpersisted token state before shutting down
+    persistTokenState();
   }
 
   protected void persistTokenState() {
@@ -114,6 +149,12 @@
         aliasService.addAliasesForCluster(AliasService.NO_CLUSTER_NAME, aliases);
         for (String tokenId : tokenIds) {
           log.createdTokenStateAliases(tokenId);
+          // After the aliases have been successfully persisted, remove their associated state from the journal
+          try {
+            journal.remove(tokenId);
+          } catch (IOException e) {
+            log.failedToRemoveJournalEntry(tokenId, e);
+          }
         }
       } catch (AliasServiceException e) {
         log.failedToCreateTokenStateAliases(e);
@@ -134,6 +175,12 @@
     synchronized (unpersistedState) {
       unpersistedState.add(new TokenExpiration(tokenId, expiration));
     }
+
+    try {
+      journal.add(tokenId, issueTime, expiration, maxLifetimeDuration);
+    } catch (IOException e) {
+      log.failedToAddJournalEntry(tokenId, e);
+    }
   }
 
   @Override
@@ -166,8 +213,10 @@
 
   @Override
   public long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException {
-    // Check the in-memory collection first and return immediately if associated record found there
+    // Check the in-memory collection first, to avoid costly keystore access when possible
     try {
+      // If the token identifier is valid, and the associated state is available from the in-memory cache, then
+      // return the expiration from there.
       return super.getTokenExpiration(tokenId, validate);
     } catch (UnknownTokenException e) {
       // It's not in memory
@@ -177,13 +226,13 @@
       validateToken(tokenId);
     }
 
-    // If there is no associated record in the in-memory collection, proceed to check the alias service
+    // If there is no associated state in the in-memory cache, proceed to check the alias service
     long expiration = 0;
     try {
       char[] expStr = aliasService.getPasswordFromAliasForCluster(AliasService.NO_CLUSTER_NAME, tokenId);
       if (expStr != null) {
         expiration = Long.parseLong(new String(expStr));
-        // Update the in-memory record
+        // Update the in-memory cache to avoid subsequent keystore look-ups for the same state
         super.updateExpiration(tokenId, expiration);
       }
     } catch (Exception e) {
@@ -215,6 +264,20 @@
 
   @Override
   protected void removeTokens(Set<String> tokenIds) throws UnknownTokenException {
+
+    // If any of the token IDs is represented among the unpersisted state, remove the associated state
+    synchronized (unpersistedState) {
+      List<TokenState> unpersistedToRemove = new ArrayList<>();
+      for (TokenState state : unpersistedState) {
+        if (tokenIds.contains(state.getTokenId())) {
+          unpersistedToRemove.add(state);
+        }
+      }
+      for (TokenState state : unpersistedToRemove) {
+        unpersistedState.remove(state);
+      }
+    }
+
     // Add the max lifetime aliases to the list of aliases to remove
     Set<String> aliasesToRemove = new HashSet<>(tokenIds);
     for (String tokenId : tokenIds) {
@@ -267,7 +330,7 @@
     return (tokenIds != null ? tokenIds : Collections.emptyList());
   }
 
-  private interface TokenState {
+  interface TokenState {
     String getTokenId();
     String getAlias();
     String getAliasValue();
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
index a85f3bf..a3d7502 100644
--- a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/TokenStateServiceMessages.java
@@ -97,4 +97,34 @@
   @Message(level = MessageLevel.INFO, text = "Removed token state aliases for {0}")
   void removedTokenStateAliases(String tokenId);
 
+  @Message(level = MessageLevel.INFO, text = "Loading peristed token state journal entries")
+  void loadingPersistedJournalEntries();
+
+  @Message(level = MessageLevel.DEBUG, text = "Loaded peristed token state journal entry for {0}")
+  void loadedPersistedJournalEntry(String tokenId);
+
+  @Message(level = MessageLevel.ERROR, text = "The peristed token state journal entry {0} is empty")
+  void emptyJournalEntry(String journalEntryName);
+
+  @Message(level = MessageLevel.INFO, text = "Added token state journal entry for {0}")
+  void addedJournalEntry(String tokenId);
+
+  @Message(level = MessageLevel.INFO, text = "Removed token state journal entry for {0}")
+  void removedJournalEntry(String tokenId);
+
+  @Message(level = MessageLevel.INFO, text = "Token state journal entry not found for {0}")
+  void journalEntryNotFound(String tokenId);
+
+  @Message(level = MessageLevel.DEBUG, text = "Persisting token state journal entry as {0}")
+  void persistingJournalEntry(String journalEntryFilename);
+
+  @Message(level = MessageLevel.ERROR, text = "Failed to persisting token state journal entry for {0} : {1}")
+  void failedToPersistJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR, text = "Failed to add a token state journal entry for {0} : {1}")
+  void failedToAddJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
+  @Message(level = MessageLevel.ERROR, text = "Failed to remove the token state journal entry for {0} : {1}")
+  void failedToRemoveJournalEntry(String tokenId, @StackTrace(level = MessageLevel.DEBUG) Exception e);
+
 }
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java
new file mode 100644
index 0000000..31a8954
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournal.java
@@ -0,0 +1,221 @@
+/*
+ *
+ * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.i18n.messages.MessagesFactory;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.TokenStateServiceMessages;
+
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+
+/**
+ * Base class for TokenStateJournal implementations that employ files for persistence.
+ */
+abstract class FileTokenStateJournal implements TokenStateJournal {
+
+    protected static final int INDEX_TOKEN_ID     = 0;
+    protected static final int INDEX_ISSUE_TIME   = 1;
+    protected static final int INDEX_EXPIRATION   = 2;
+    protected static final int INDEX_MAX_LIFETIME = 3;
+
+    protected static final TokenStateServiceMessages log = MessagesFactory.get(TokenStateServiceMessages.class);
+
+    // The name of the journal directory
+    protected static final String JOURNAL_DIR_NAME = "token-state";
+
+    /**
+     * The journal directory path
+     */
+    protected final Path journalDir;
+
+    protected FileTokenStateJournal(GatewayConfig config) throws IOException {
+        journalDir = Paths.get(config.getGatewaySecurityDir(), JOURNAL_DIR_NAME);
+        if (!Files.exists(journalDir)) {
+            Files.createDirectories(journalDir);
+        }
+    }
+
+    @Override
+    public abstract void add(String tokenId, long issueTime, long expiration, long maxLifetime) throws IOException;
+
+    @Override
+    public void add(JournalEntry entry) throws IOException {
+        add(Collections.singletonList(entry));
+    }
+
+    @Override
+    public abstract void add(List<JournalEntry> entries) throws IOException;
+
+    @Override
+    public List<JournalEntry> get() throws IOException {
+        return loadJournal();
+    }
+
+    @Override
+    public abstract JournalEntry get(String tokenId) throws IOException;
+
+    @Override
+    public void remove(final String tokenId) throws IOException {
+        remove(Collections.singleton(tokenId));
+    }
+
+    @Override
+    public abstract void remove(Collection<String> tokenIds) throws IOException;
+
+    @Override
+    public void remove(final JournalEntry entry) throws IOException {
+        remove(entry.getTokenId());
+    }
+
+    protected abstract List<JournalEntry> loadJournal() throws IOException;
+
+    protected List<FileJournalEntry> loadJournal(FileChannel channel) throws IOException {
+        List<FileJournalEntry> entries = new ArrayList<>();
+
+        try (InputStream input = Channels.newInputStream(channel)) {
+            BufferedReader reader = new BufferedReader(new InputStreamReader(input, StandardCharsets.UTF_8));
+            String line;
+            while ((line = reader.readLine()) != null) {
+                entries.add(FileJournalEntry.parse(line));
+            }
+        }
+
+        return entries;
+    }
+
+    /**
+     * Parse the String representation of an entry.
+     *
+     * @param entry A journal file entry line
+     *
+     * @return A FileJournalEntry object created from the specified entry.
+     */
+    protected FileJournalEntry parse(final String entry) {
+        return FileJournalEntry.parse(entry);
+    }
+
+    /**
+     * A JournalEntry implementation for File-based TokenStateJournal implementations
+     */
+    static final class FileJournalEntry implements JournalEntry {
+        private final String tokenId;
+        private final String issueTime;
+        private final String expiration;
+        private final String maxLifetime;
+
+        FileJournalEntry(final String tokenId, long issueTime, long expiration, long maxLifetime) {
+            this(tokenId, String.valueOf(issueTime), String.valueOf(expiration), String.valueOf(maxLifetime));
+        }
+
+        FileJournalEntry(final String tokenId,
+                         final String issueTime,
+                         final String expiration,
+                         final String maxLifetime) {
+            this.tokenId = tokenId;
+            this.issueTime = issueTime;
+            this.expiration = expiration;
+            this.maxLifetime = maxLifetime;
+        }
+
+        @Override
+        public String getTokenId() {
+            return tokenId;
+        }
+
+        @Override
+        public String getIssueTime() {
+            return issueTime;
+        }
+
+        @Override
+        public String getExpiration() {
+            return expiration;
+        }
+
+        @Override
+        public String getMaxLifetime() {
+            return maxLifetime;
+        }
+
+        @Override
+        public String toString() {
+            String[] elements = new String[4];
+
+            elements[INDEX_TOKEN_ID] = getTokenId();
+
+            String issueTime = getIssueTime();
+            elements[INDEX_ISSUE_TIME] = (issueTime != null) ? issueTime : "";
+
+            String expiration = getExpiration();
+            elements[INDEX_EXPIRATION] = (expiration != null) ? expiration : "";
+
+            String maxLifetime = getMaxLifetime();
+            elements[INDEX_MAX_LIFETIME] = (maxLifetime != null) ? maxLifetime : "";
+
+            return String.format(Locale.ROOT,
+                                 "%s,%s,%s,%s",
+                                 elements[INDEX_TOKEN_ID],
+                                 elements[INDEX_ISSUE_TIME],
+                                 elements[INDEX_EXPIRATION],
+                                 elements[INDEX_MAX_LIFETIME]);
+        }
+
+        /**
+          * Parse the String representation of an entry.
+          *
+          * @param entry A journal file entry line
+          *
+          * @return A FileJournalEntry object created from the specified entry.
+          */
+        static FileJournalEntry parse(final String entry) {
+            String[] elements = entry.split(",");
+            if (elements.length < 4) {
+                throw new IllegalArgumentException("Invalid journal entry: " + entry);
+            }
+
+            String tokenId     = elements[INDEX_TOKEN_ID].trim();
+            String issueTime   = elements[INDEX_ISSUE_TIME].trim();
+            String expiration  = elements[INDEX_EXPIRATION].trim();
+            String maxLifetime = elements[INDEX_MAX_LIFETIME].trim();
+
+            return new FileJournalEntry(tokenId.isEmpty() ? null : tokenId,
+                                        issueTime.isEmpty() ? null : issueTime,
+                                        expiration.isEmpty() ? null : expiration,
+                                        maxLifetime.isEmpty() ? null : maxLifetime);
+        }
+
+    }
+
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java
new file mode 100644
index 0000000..dfdd1e1
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournal.java
@@ -0,0 +1,142 @@
+/*
+ *
+ * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+
+import java.io.BufferedWriter;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.io.OutputStreamWriter;
+import java.nio.channels.Channels;
+import java.nio.channels.FileChannel;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardOpenOption;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * A TokenStateJournal implementation that manages separate files for token state.
+ */
+class MultiFileTokenStateJournal extends FileTokenStateJournal {
+
+    // File extension for journal entry files
+    static final String ENTRY_FILE_EXT = ".ts";
+
+    // Filter used when listing all journal entry files in the journal directory
+    static final String ENTRY_FILE_EXT_FILTER = "*" + ENTRY_FILE_EXT;
+
+    MultiFileTokenStateJournal(GatewayConfig config) throws IOException {
+        super(config);
+    }
+
+    @Override
+    public void add(final String tokenId, long issueTime, long expiration, long maxLifetime) throws IOException {
+        add(Collections.singletonList(new FileJournalEntry(tokenId, issueTime, expiration, maxLifetime)));
+    }
+
+    @Override
+    public void add(final List<JournalEntry> entries) throws IOException {
+        // Persist each journal entry as an individual file in the journal directory
+        for (JournalEntry entry : entries) {
+            final Path entryFile = journalDir.resolve(entry.getTokenId() + ENTRY_FILE_EXT);
+            log.persistingJournalEntry(entryFile.toString());
+            try (FileChannel fileChannel = FileChannel.open(entryFile, StandardOpenOption.WRITE,
+                    StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING)) {
+                fileChannel.lock();
+                try (OutputStream out = Channels.newOutputStream(fileChannel)) {
+                    BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.UTF_8));
+                    writer.write(entry.toString());
+                    writer.newLine();
+                    writer.flush();
+                }
+                log.addedJournalEntry(entry.getTokenId());
+            } catch (IOException e){
+                log.failedToPersistJournalEntry(entry.getTokenId(), e);
+                throw e;
+            }
+        }
+    }
+
+    @Override
+    public JournalEntry get(final String tokenId) throws IOException {
+        JournalEntry result = null;
+
+        Path entryFilePath = journalDir.resolve(tokenId + ENTRY_FILE_EXT);
+        if (Files.exists(entryFilePath)) {
+            try (FileChannel fileChannel = FileChannel.open(entryFilePath, StandardOpenOption.READ)) {
+                fileChannel.lock(0L, Long.MAX_VALUE, true);
+                List<FileJournalEntry> entries = loadJournal(fileChannel);
+                if (entries.isEmpty()) {
+                    log.journalEntryNotFound(tokenId);
+                } else {
+                    result = entries.get(0);
+                }
+            }
+        } else {
+            log.journalEntryNotFound(tokenId);
+        }
+
+        return result;
+    }
+
+    @Override
+    public void remove(final Collection<String> tokenIds) throws IOException {
+        // Remove the journal entry files corresponding to the specified token identifiers
+        for (String tokenId : tokenIds) {
+            Path entryFilePath = journalDir.resolve(tokenId + ENTRY_FILE_EXT);
+            if (Files.exists(entryFilePath)) {
+                Files.delete(entryFilePath);
+                log.removedJournalEntry(tokenId);
+            }
+        }
+    }
+
+    @Override
+    protected List<JournalEntry> loadJournal() throws IOException {
+        List<JournalEntry> entries = new ArrayList<>();
+
+        // List all the journal entry files in the directory, and create journal entries for them
+        if (Files.exists(journalDir)) {
+            log.loadingPersistedJournalEntries();
+            try (DirectoryStream<Path> stream = Files.newDirectoryStream(journalDir, ENTRY_FILE_EXT_FILTER)) {
+                for (Path entryFilePath : stream ) {
+                    try (FileChannel fileChannel = FileChannel.open(entryFilePath, StandardOpenOption.READ)) {
+                        fileChannel.lock(0L, Long.MAX_VALUE, true);
+                        entries.addAll(loadJournal(fileChannel));
+                        if (entries.isEmpty()) {
+                            log.emptyJournalEntry(entryFilePath.toString());
+                        } else {
+                            // Should only be a single entry for this implementation
+                            log.loadedPersistedJournalEntry(entries.get(0).getTokenId());
+                        }
+                    }
+                }
+            }
+        }
+
+        return entries;
+    }
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java
new file mode 100644
index 0000000..2f3d43b
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/impl/state/TokenStateJournalFactory.java
@@ -0,0 +1,32 @@
+/*
+ *
+ * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+
+import java.io.IOException;
+
+public class TokenStateJournalFactory {
+
+    public static TokenStateJournal create(GatewayConfig config) throws IOException {
+        return new MultiFileTokenStateJournal(config);
+    }
+
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java
new file mode 100644
index 0000000..d520f45
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/JournalEntry.java
@@ -0,0 +1,51 @@
+/*
+ *
+ *  * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.state;
+
+/**
+ * An entry in the TokenStateJournal
+ */
+public interface JournalEntry {
+
+    /**
+     *
+     * @return The unique token identifier for which this entry is defined.
+     */
+    String getTokenId();
+
+    /**
+     *
+     * @return The token's issue time (milliseconds since the epoch) as a String.
+     */
+    String getIssueTime();
+
+    /**
+     *
+     * @return The token's expiration time (milliseconds since the epoch) as a String.
+     */
+    String getExpiration();
+
+    /**
+     * The token's maximum allowed lifetime, beyond which its expiration cannot be extended,
+     * (milliseconds since the epoch) as a String.
+     *
+     * @return The token's maximum allowed lifetime
+     */
+    String getMaxLifetime();
+}
diff --git a/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java
new file mode 100644
index 0000000..f9aa01b
--- /dev/null
+++ b/gateway-server/src/main/java/org/apache/knox/gateway/services/token/state/TokenStateJournal.java
@@ -0,0 +1,92 @@
+/*
+ *
+ *  * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.state;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.List;
+
+/**
+ *
+ */
+public interface TokenStateJournal {
+
+    /**
+     * Persist the token state to the journal.
+     *
+     * @param tokenId     The unique token identifier
+     * @param issueTime   The issue timestamp
+     * @param expiration  The expiration time
+     * @param maxLifetime The maximum allowed lifetime
+     */
+    void add(String tokenId, long issueTime, long expiration, long maxLifetime)
+        throws IOException;
+
+    /**
+     * Persist the token state to the journal.
+     *
+     * @param entry The entry to persist
+     */
+    void add(JournalEntry entry) throws IOException;
+
+    /**
+     * Persist the token state to the journal.
+     *
+     * @param entries The entries to persist
+     */
+    void add(List<JournalEntry> entries) throws IOException;
+
+    /**
+     * Get the journaled state for the specified token identifier.
+     *
+     * @param tokenId The unique token identifier.
+     *
+     * @return A JournalEntry with the specified token's journaled state.
+     */
+    JournalEntry get(String tokenId) throws IOException;
+
+    /**
+     * Get all the the journaled tokens' state.
+     *
+     * @return A List of JournalEntry objects.
+     */
+    List<JournalEntry> get() throws IOException;
+
+    /**
+     * Remove the token state for the specified token from the journal
+     *
+     * @param tokenId The unique token identifier
+     */
+    void remove(String tokenId) throws IOException;
+
+    /**
+     * Remove the token state for the specified tokens from the journal
+     *
+     * @param tokenIds A set of unique token identifiers
+     */
+    void remove(Collection<String> tokenIds) throws IOException;
+
+    /**
+     * Remove the token state for the specified journal entry
+     *
+     * @param entry A JournalEntry for the token for which the state should be removed
+     */
+    void remove(JournalEntry entry) throws IOException;
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
index cd4d063..6a08635 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/AliasBasedTokenStateServiceTest.java
@@ -22,27 +22,59 @@
 import org.apache.knox.gateway.services.security.AliasServiceException;
 import org.apache.knox.gateway.services.security.token.TokenStateService;
 import org.apache.knox.gateway.services.security.token.impl.JWTToken;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.apache.knox.gateway.services.token.impl.state.TokenStateJournalFactory;
 import org.easymock.EasyMock;
+import org.junit.Rule;
 import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
 
+import java.io.IOException;
 import java.lang.reflect.Field;
 import java.lang.reflect.Method;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
 import java.security.cert.Certificate;
 import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
 
 import static org.easymock.EasyMock.anyObject;
 import static org.easymock.EasyMock.anyString;
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
 
 public class AliasBasedTokenStateServiceTest extends DefaultTokenStateServiceTest {
 
+  @Rule
+  public final TemporaryFolder testFolder = new TemporaryFolder();
+
+  private Path gatewaySecurityDir;
+
+  private Long tokenStatePersistenceInterval = TimeUnit.SECONDS.toMillis(15);
+
+  @Override
+  protected String getGatewaySecurityDir() throws IOException {
+    if (gatewaySecurityDir == null) {
+      gatewaySecurityDir = testFolder.newFolder().toPath();
+      Files.createDirectories(gatewaySecurityDir);
+    }
+    return gatewaySecurityDir.toString();
+  }
+
+  @Override
+  protected long getTokenStatePersistenceInterval() {
+    return (tokenStatePersistenceInterval != null) ? tokenStatePersistenceInterval : super.getTokenStatePersistenceInterval();
+  }
+
   /**
    * KNOX-2375
    */
@@ -105,8 +137,10 @@
 
   @Test
   public void testAddAndRemoveTokenIncludesCache() throws Exception {
+    final int TOKEN_COUNT = 10;
+
     final Set<JWTToken> testTokens = new HashSet<>();
-    for (int i = 0; i < 10 ; i++) {
+    for (int i = 0; i < TOKEN_COUNT ; i++) {
       testTokens.add(createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60)));
     }
 
@@ -161,6 +195,7 @@
 
       // Sleep to allow the eviction evaluation to be performed
       Thread.sleep(evictionInterval + (evictionInterval / 4));
+
     } finally {
       tss.stop();
     }
@@ -270,6 +305,8 @@
   @Test
   public void testGetMaxLifetimeUsesCache() throws Exception {
     AliasService aliasService = EasyMock.createMock(AliasService.class);
+    aliasService.addAliasesForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().once(); // Expecting this during shutdown
 
     EasyMock.replay(aliasService);
 
@@ -332,6 +369,8 @@
     EasyMock.expectLastCall().andVoid().atLeastOnce();
     aliasService.removeAliasForCluster(anyString(), anyObject());
     EasyMock.expectLastCall().andVoid().atLeastOnce();
+    aliasService.addAliasesForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().andVoid().once(); // Expecting this during shutdown
 
     EasyMock.replay(aliasService);
 
@@ -372,11 +411,14 @@
         tss.updateExpiration(tokenId, updatedExpiration);
       }
 
-      //invoking with true/false validation flags as it should not affect if values are coming from the cache
+      // Invoking with true/false validation flags as it should not affect if values are coming from the cache
       int count = 0;
       for (String tokenId : tokenExpirations.keySet()) {
-        assertEquals("Expected the cached expiration to have been updated.", updatedExpiration, tss.getTokenExpiration(tokenId, count++ % 2 == 0));
+        assertEquals("Expected the cached expiration to have been updated.",
+                     updatedExpiration,
+                     tss.getTokenExpiration(tokenId, count++ % 2 == 0));
       }
+
     } finally {
       tss.stop();
     }
@@ -385,8 +427,145 @@
     EasyMock.verify(aliasService);
   }
 
+  @Test
+  public void testTokenStateJournaling() throws Exception {
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+    aliasService.getAliasesForCluster(anyString());
+    EasyMock.expectLastCall().andReturn(Collections.emptyList()).anyTimes();
+    aliasService.addAliasesForCluster(anyString(), anyObject());
+    EasyMock.expectLastCall().once();
+    EasyMock.replay(aliasService);
+
+    tokenStatePersistenceInterval = 1L; // Override the persistence interval for this test
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+    initTokenStateService(tss);
+
+    Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+    maxTokenLifetimesField.setAccessible(true);
+    Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+    Path journalDir = Paths.get(getGatewaySecurityDir(), "token-state");
+
+    final long evictionInterval = TimeUnit.SECONDS.toMillis(3);
+    final long maxTokenLifetime = evictionInterval * 3;
+
+    final List<String> tokenIds = new ArrayList<>();
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < 10 ; i++) {
+      JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
+      testTokens.add(token);
+      tokenIds.add(token.getClaim(JWTToken.KNOX_ID_CLAIM));
+    }
+
+    try {
+      tss.start();
+
+      // Add the expired tokens
+      for (JWTToken token : testTokens) {
+        tss.addToken(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                     System.currentTimeMillis(),
+                     token.getExpiresDate().getTime(),
+                     maxTokenLifetime);
+      }
+
+      assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+                   10,
+                   maxTokenLifetimes.size());
+
+      // Check for the expected number of files corresponding to journal entries
+      List<Path> listing = Files.list(journalDir).collect(Collectors.toList());
+      assertFalse(listing.isEmpty());
+      assertEquals(10, listing.size());
+
+      // Validate the journal entry file names
+      for (Path p : listing) {
+        Path filename = p.getFileName();
+        String filenameString = filename.toString();
+        assertTrue(filenameString.endsWith(".ts"));
+        String tokenId = filenameString.substring(0, filenameString.length() - 3);
+        assertTrue(tokenIds.contains(tokenId));
+      }
+
+      // Sleep to allow the persistence to be performed
+      Thread.sleep(TimeUnit.SECONDS.toMillis(tokenStatePersistenceInterval) * 2);
+
+    } finally {
+      tss.stop();
+      tokenStatePersistenceInterval = null;
+    }
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+
+    // Verify that the journal entries were removed when the aliases were created
+    List<Path> listing = Files.list(journalDir).collect(Collectors.toList());
+    assertTrue(listing.isEmpty());
+  }
+
+  @Test
+  public void testLoadTokenStateJournalDuringInit() throws Exception {
+    final int TOKEN_COUNT = 10;
+
+    AliasService aliasService = EasyMock.createMock(AliasService.class);
+    aliasService.getAliasesForCluster(anyString());
+    EasyMock.expectLastCall().andReturn(Collections.emptyList()).anyTimes();
+    EasyMock.replay(aliasService);
+
+    // Create some test tokens
+    final Set<JWTToken> testTokens = new HashSet<>();
+    for (int i = 0; i < TOKEN_COUNT ; i++) {
+      JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
+      testTokens.add(token);
+    }
+
+    // Persist the token state journal entries before initializing the TokenStateService
+    TokenStateJournal journal = TokenStateJournalFactory.create(createMockGatewayConfig(false));
+    for (JWTToken token : testTokens) {
+      journal.add(token.getClaim(JWTToken.KNOX_ID_CLAIM),
+                  System.currentTimeMillis(),
+                  token.getExpiresDate().getTime(),
+                  System.currentTimeMillis() + TimeUnit.HOURS.toMillis(24));
+    }
+
+    AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
+    tss.setAliasService(aliasService);
+
+    // Initialize the service, and presumably load the previously-persisted journal entries
+    initTokenStateService(tss);
+
+    Field tokenExpirationsField = tss.getClass().getSuperclass().getDeclaredField("tokenExpirations");
+    tokenExpirationsField.setAccessible(true);
+    Map<String, Long> tokenExpirations = (Map<String, Long>) tokenExpirationsField.get(tss);
+
+    Field maxTokenLifetimesField = tss.getClass().getSuperclass().getDeclaredField("maxTokenLifetimes");
+    maxTokenLifetimesField.setAccessible(true);
+    Map<String, Long> maxTokenLifetimes = (Map<String, Long>) maxTokenLifetimesField.get(tss);
+
+    Field unpersistedStateField = tss.getClass().getDeclaredField("unpersistedState");
+    unpersistedStateField.setAccessible(true);
+    List<AliasBasedTokenStateService.TokenState> unpersistedState =
+            (List<AliasBasedTokenStateService.TokenState>) unpersistedStateField.get(tss);
+
+    assertEquals("Expected the tokens expirations to have been added in the base class cache.",
+                 TOKEN_COUNT,
+                 tokenExpirations.size());
+
+    assertEquals("Expected the tokens lifetimes to have been added in the base class cache.",
+                 TOKEN_COUNT,
+                 maxTokenLifetimes.size());
+
+    assertEquals("Expected the unpersisted state to have been added.",
+                 (TOKEN_COUNT * 2), // Two TokenState entries per token (expiration, max lifetime)
+                 unpersistedState.size());
+
+    // Verify that the expected methods were invoked
+    EasyMock.verify(aliasService);
+  }
+
   @Override
-  protected TokenStateService createTokenStateService() {
+  protected TokenStateService createTokenStateService() throws Exception {
     AliasBasedTokenStateService tss = new AliasBasedTokenStateService();
     tss.setAliasService(new TestAliasService());
     initTokenStateService(tss);
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
index 27d38bf..190dd70 100644
--- a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/DefaultTokenStateServiceTest.java
@@ -29,6 +29,7 @@
 import org.junit.BeforeClass;
 import org.junit.Test;
 
+import java.io.IOException;
 import java.security.KeyPair;
 import java.security.KeyPairGenerator;
 import java.security.interfaces.RSAPrivateKey;
@@ -165,7 +166,7 @@
   }
 
   @Test
-  public void testNegativeTokenEviction() throws InterruptedException, UnknownTokenException {
+  public void testNegativeTokenEviction() throws Exception {
     final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
@@ -189,7 +190,7 @@
   }
 
   @Test
-  public void testTokenEviction() throws InterruptedException, ServiceLifecycleException, UnknownTokenException {
+  public void testTokenEviction() throws Exception {
     final JWTToken token = createMockToken(System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(60));
     final TokenStateService tss = createTokenStateService();
 
@@ -218,7 +219,7 @@
   }
 
   @Test
-  public void testTokenPermissiveness() throws UnknownTokenException {
+  public void testTokenPermissiveness() throws Exception {
     final long expiry = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(300);
     final JWT token = getJWTToken(expiry);
     TokenStateService tss = new DefaultTokenStateService();
@@ -232,7 +233,7 @@
   }
 
   @Test(expected = UnknownTokenException.class)
-  public void testTokenPermissivenessNoExpiry() throws UnknownTokenException {
+  public void testTokenPermissivenessNoExpiry() throws Exception {
     final JWT token = getJWTToken(-1L);
     TokenStateService tss = new DefaultTokenStateService();
     try {
@@ -258,17 +259,25 @@
     return token;
   }
 
-  protected static GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness) {
+  protected GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness) throws Exception {
+    return createMockGatewayConfig(tokenPermissiveness, getGatewaySecurityDir(), getTokenStatePersistenceInterval());
+  }
+
+  protected GatewayConfig createMockGatewayConfig(boolean tokenPermissiveness,
+                                                         final String securityDir,
+                                                         long statePersistenceInterval) {
     GatewayConfig config = EasyMock.createNiceMock(GatewayConfig.class);
     /* configure token eviction time to be 2 secs for test */
     EasyMock.expect(config.getKnoxTokenEvictionInterval()).andReturn(2L).anyTimes();
     EasyMock.expect(config.getKnoxTokenEvictionGracePeriod()).andReturn(0L).anyTimes();
     EasyMock.expect(config.isKnoxTokenPermissiveValidationEnabled()).andReturn(tokenPermissiveness).anyTimes();
+    EasyMock.expect(config.getKnoxTokenStateAliasPersistenceInterval()).andReturn(statePersistenceInterval).anyTimes();
+    EasyMock.expect(config.getGatewaySecurityDir()).andReturn(securityDir).anyTimes();
     EasyMock.replay(config);
     return config;
   }
 
-  protected void initTokenStateService(TokenStateService tss) {
+  protected void initTokenStateService(TokenStateService tss) throws Exception {
     try {
       tss.init(createMockGatewayConfig(false), Collections.emptyMap());
     } catch (ServiceLifecycleException e) {
@@ -276,7 +285,15 @@
     }
   }
 
-  protected TokenStateService createTokenStateService() {
+  protected long getTokenStatePersistenceInterval() {
+    return TimeUnit.SECONDS.toMillis(15);
+  }
+
+  protected String getGatewaySecurityDir() throws IOException {
+    return null;
+  }
+
+  protected TokenStateService createTokenStateService() throws Exception {
     TokenStateService tss = new DefaultTokenStateService();
     initTokenStateService(tss);
     return tss;
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java
new file mode 100644
index 0000000..d0a385c
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/AbstractFileTokenStateJournalTest.java
@@ -0,0 +1,230 @@
+/*
+ *
+ * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.config.impl.GatewayConfigImpl;
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public abstract class AbstractFileTokenStateJournalTest {
+
+    @Rule
+    public final TemporaryFolder testFolder = new TemporaryFolder();
+
+    abstract TokenStateJournal createTokenStateJournal(GatewayConfig config) throws IOException;
+
+    protected JournalEntry createTestJournalEntry(final String tokenId,
+                                                        long issueTime,
+                                                        long expiration,
+                                                        long maxLifetime) {
+        return new FileTokenStateJournal.FileJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+    }
+
+    protected GatewayConfig getGatewayConfig() throws IOException {
+        final Path dataDir = testFolder.newFolder().toPath();
+        System.out.println("dataDir : " + dataDir.toString());
+        Files.createDirectories(dataDir.resolve("security")); // Make sure the security directory exists
+
+        GatewayConfigImpl config = new GatewayConfigImpl();
+        config.set("gateway.data.dir", dataDir.toString());
+        return config;
+    }
+
+    @Test
+    public void testSingleTokenRoundTrip() throws Exception {
+        GatewayConfig config = getGatewayConfig();
+
+        TokenStateJournal journal = createTokenStateJournal(config);
+
+        final String tokenId = String.valueOf(UUID.randomUUID());
+
+        // Verify that the token state has not yet been journaled
+        assertNull(journal.get(tokenId));
+
+        long issueTime = System.currentTimeMillis();
+        long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+        long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+        journal.add(tokenId, issueTime, expiration, maxLifetime);
+
+        // Get the token state from the journal, and validate its contents
+        JournalEntry entry = journal.get(tokenId);
+        assertNotNull(entry);
+        assertEquals(tokenId, entry.getTokenId());
+        assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+        assertEquals(expiration, Long.parseLong(entry.getExpiration()));
+        assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+        journal.remove(tokenId);
+
+        // Verify that the token state can no longer be gotten from the journal
+        assertNull(journal.get(tokenId));
+    }
+
+    @Test
+    public void testUpdateTokenState() throws Exception {
+        GatewayConfig config = getGatewayConfig();
+
+        TokenStateJournal journal = createTokenStateJournal(config);
+
+        final String tokenId = String.valueOf(UUID.randomUUID());
+
+        // Verify that the token state has not yet been journaled
+        assertNull(journal.get(tokenId));
+
+        long issueTime = System.currentTimeMillis();
+        long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+        long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+        journal.add(tokenId, issueTime, expiration, maxLifetime);
+
+        // Get the token state from the journal, and validate its contents
+        JournalEntry entry = journal.get(tokenId);
+        assertNotNull(entry);
+        assertEquals(tokenId, entry.getTokenId());
+        assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+        assertEquals(expiration, Long.parseLong(entry.getExpiration()));
+        assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+        long updatedExpiration = System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(5);
+        journal.add(tokenId, issueTime, updatedExpiration, maxLifetime);
+
+        // Get and validate the updated token state
+        entry = journal.get(tokenId);
+        assertNotNull(entry);
+        assertEquals(tokenId, entry.getTokenId());
+        assertEquals(issueTime, Long.parseLong(entry.getIssueTime()));
+        assertEquals(updatedExpiration, Long.parseLong(entry.getExpiration()));
+        assertEquals(maxLifetime, Long.parseLong(entry.getMaxLifetime()));
+
+        // Verify that the token state can no longer be gotten from the journal
+        journal.remove(tokenId);
+        assertNull(journal.get(tokenId));
+    }
+
+    @Test
+    public void testSingleJournalEntryRoundTrip() throws Exception {
+        GatewayConfig config = getGatewayConfig();
+
+        TokenStateJournal journal = createTokenStateJournal(config);
+
+        final String tokenId = String.valueOf(UUID.randomUUID());
+
+        // Verify that the token state has not yet been journaled
+        assertNull(journal.get(tokenId));
+
+        long issueTime = System.currentTimeMillis();
+        long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+        long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+        JournalEntry original = createTestJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+        journal.add(original);
+
+        // Get the token state from the journal, and validate its contents
+        JournalEntry entry = journal.get(tokenId);
+        assertNotNull(entry);
+        assertEquals(original.getTokenId(), entry.getTokenId());
+        assertEquals(original.getIssueTime(), entry.getIssueTime());
+        assertEquals(original.getExpiration(), entry.getExpiration());
+        assertEquals(original.getMaxLifetime(), entry.getMaxLifetime());
+
+        journal.remove(entry);
+
+        // Verify that the token state can no longer be gotten from the journal
+        assertNull(journal.get(tokenId));
+    }
+
+    @Test
+    public void testMultipleTokensRoundTrip() throws Exception {
+        GatewayConfig config = getGatewayConfig();
+
+        TokenStateJournal journal = createTokenStateJournal(config);
+
+        final List<String> tokenIds = new ArrayList<>();
+        for (int i = 0; i < 10; i++) {
+            tokenIds.add(String.valueOf(UUID.randomUUID()));
+        }
+
+        Map<String, JournalEntry> journalEntries = new HashMap<>();
+
+        // Verify that the token state has not yet been journaled, and create a JournalEntry for it
+        for (String tokenId : tokenIds) {
+            assertNull(journal.get(tokenId));
+
+            long issueTime = System.currentTimeMillis();
+            long expiration = issueTime + TimeUnit.MINUTES.toMillis(5);
+            long maxLifetime = issueTime + (5 * TimeUnit.MINUTES.toMillis(5));
+            journalEntries.put(tokenId, createTestJournalEntry(tokenId, issueTime, expiration, maxLifetime));
+        }
+
+        for (JournalEntry entry : journalEntries.values()) {
+            journal.add(entry);
+        }
+
+        for (Map.Entry<String, JournalEntry> journalEntry : journalEntries.entrySet()) {
+            final String tokenId = journalEntry.getKey();
+            // Get the token state from the journal, and validate its contents
+            JournalEntry entry = journal.get(tokenId);
+            assertNotNull(entry);
+
+            JournalEntry original = journalEntry.getValue();
+            assertEquals(original.getTokenId(), entry.getTokenId());
+            assertEquals(original.getIssueTime(), entry.getIssueTime());
+            assertEquals(original.getExpiration(), entry.getExpiration());
+            assertEquals(original.getMaxLifetime(), entry.getMaxLifetime());
+        }
+
+        // Test loading of persisted token state
+        List<JournalEntry> loadedEntries = journal.get();
+        assertNotNull(loadedEntries);
+        assertFalse(loadedEntries.isEmpty());
+        assertEquals(10, loadedEntries.size());
+        for (JournalEntry loaded : loadedEntries) {
+            JournalEntry original = journalEntries.get(loaded.getTokenId());
+            assertNotNull(original);
+            assertEquals(original.getTokenId(), loaded.getTokenId());
+            assertEquals(original.getIssueTime(), loaded.getIssueTime());
+            assertEquals(original.getExpiration(), loaded.getExpiration());
+            assertEquals(original.getMaxLifetime(), loaded.getMaxLifetime());
+        }
+
+        for (String tokenId : tokenIds) {
+            journal.remove(tokenId);
+            // Verify that the token state can no longer be gotten from the journal
+            assertNull(journal.get(tokenId));
+        }
+    }
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java
new file mode 100644
index 0000000..da79a4c
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/FileTokenStateJournalTest.java
@@ -0,0 +1,133 @@
+/*
+ *
+ * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.services.token.state.JournalEntry;
+import org.junit.Test;
+
+import java.util.UUID;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+
+public class FileTokenStateJournalTest {
+
+    @Test
+    public void testParseJournalEntry() {
+        final String tokenId     = UUID.randomUUID().toString();
+        final Long   issueTime   = System.currentTimeMillis();
+        final Long   expiration  = issueTime + TimeUnit.HOURS.toMillis(1);
+        final Long   maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+        doTestParseJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+    }
+
+    @Test(expected = IllegalArgumentException.class)
+    public void testParseJournalEntry_MissingMaxLifetime() {
+        final String tokenId     = UUID.randomUUID().toString();
+        final Long   issueTime   = System.currentTimeMillis();
+        final Long   expiration  = issueTime + TimeUnit.HOURS.toMillis(1);
+        final Long   maxLifetime = null;
+
+        doTestParseJournalEntry(tokenId, issueTime, expiration, maxLifetime);
+    }
+
+    @Test
+    public void testParseJournalEntry_MissingIssueTime() {
+        final String tokenId     = UUID.randomUUID().toString();
+        final Long   issueTime   = System.currentTimeMillis();
+        final Long   expiration  = issueTime + TimeUnit.HOURS.toMillis(1);
+        final Long   maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+        doTestParseJournalEntry(tokenId, null, expiration, maxLifetime);
+    }
+
+    @Test
+    public void testParseJournalEntry_MissingIssueAndExpirationTimes() {
+        final String tokenId = UUID.randomUUID().toString();
+        final Long   maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+        doTestParseJournalEntry(tokenId, null, null, maxLifetime);
+    }
+
+    @Test
+    public void testParseJournalEntry_OnlyMaxLifetime() {
+        final Long maxLifetime = TimeUnit.HOURS.toMillis(7);
+
+        doTestParseJournalEntry(null, null, null, maxLifetime);
+    }
+
+    @Test
+    public void testParseJournalEntry_AllMissing() {
+        doTestParseJournalEntry(null, null, null, " ");
+    }
+
+    private void doTestParseJournalEntry(final String tokenId,
+                                         final Long   issueTime,
+                                         final Long   expiration,
+                                         final Long   maxLifetime) {
+        doTestParseJournalEntry(tokenId,
+                                (issueTime != null ? issueTime.toString() : null),
+                                (expiration != null ? expiration.toString() : null),
+                                (maxLifetime != null ? maxLifetime.toString() : null));
+    }
+
+    private void doTestParseJournalEntry(final String tokenId,
+                                         final String issueTime,
+                                         final String expiration,
+                                         final String maxLifetime) {
+        StringBuilder entryStringBuilder =
+            new StringBuilder(tokenId != null ? tokenId : "").append(',')
+                                                             .append(issueTime != null ? issueTime : "")
+                                                             .append(',')
+                                                             .append(expiration != null ? expiration : "")
+                                                             .append(',')
+                                                             .append(maxLifetime != null ? maxLifetime : "");
+
+        JournalEntry entry = FileTokenStateJournal.FileJournalEntry.parse(entryStringBuilder.toString());
+        assertNotNull(entry);
+        if (tokenId != null && !tokenId.trim().isEmpty()) {
+            assertEquals(tokenId, entry.getTokenId());
+        } else {
+            assertNull(entry.getTokenId());
+        }
+
+        if (issueTime != null && !issueTime.trim().isEmpty()) {
+            assertEquals(issueTime, entry.getIssueTime());
+        } else {
+            assertNull(entry.getIssueTime());
+        }
+
+        if (expiration != null && !expiration.trim().isEmpty()) {
+            assertEquals(expiration, entry.getExpiration());
+        } else {
+            assertNull(entry.getExpiration());
+        }
+
+        if (maxLifetime != null && !maxLifetime.trim().isEmpty()) {
+            assertEquals(maxLifetime, entry.getMaxLifetime());
+        } else {
+            assertNull(entry.getMaxLifetime());
+        }
+    }
+
+
+}
diff --git a/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java
new file mode 100644
index 0000000..938a7e4
--- /dev/null
+++ b/gateway-server/src/test/java/org/apache/knox/gateway/services/token/impl/state/MultiFileTokenStateJournalTest.java
@@ -0,0 +1,33 @@
+/*
+ *
+ *  * 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.
+ *
+ */
+package org.apache.knox.gateway.services.token.impl.state;
+
+import org.apache.knox.gateway.config.GatewayConfig;
+import org.apache.knox.gateway.services.token.state.TokenStateJournal;
+
+import java.io.IOException;
+
+public class MultiFileTokenStateJournalTest extends AbstractFileTokenStateJournalTest {
+
+    @Override
+    TokenStateJournal createTokenStateJournal(GatewayConfig config) throws IOException {
+        return new MultiFileTokenStateJournal(config);
+    }
+
+}
diff --git a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
index 602cf53..d9a8a16 100644
--- a/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
+++ b/gateway-spi/src/main/java/org/apache/knox/gateway/services/security/token/TokenStateService.java
@@ -154,12 +154,15 @@
   long getTokenExpiration(String tokenId) throws UnknownTokenException;
 
   /**
-    *
-    * @param tokenId  The token unique identifier.
-    * @param validate Flag indicating whether the token needs to be validated.
-    *
-    * @return The token's expiration time in milliseconds.
-    */
+   * Get the expiration for the specified token, optionally validating the token prior to accessing its expiration.
+   * In some cases, the token has already been validated, and skipping an additional unnecessary validation improves
+   * performance.
+   *
+   * @param tokenId  The token unique identifier.
+   * @param validate Flag indicating whether the token needs to be validated.
+   *
+   * @return The token's expiration time in milliseconds.
+   */
   long getTokenExpiration(String tokenId, boolean validate) throws UnknownTokenException;
 
 }