This is an automated email from the ASF dual-hosted git repository.

riemer pushed a commit to branch support-refresh-tokens
in repository https://gitbox.apache.org/repos/asf/streampipes.git

commit e329fedcbf2625d13acfa71b34354ca79c1fac78
Author: Dominik Riemer <[email protected]>
AuthorDate: Fri Mar 6 08:31:11 2026 +0100

    feat: Support refresh tokens
---
 .../model/client/user/LoginRequest.java            |   4 +-
 .../model/client/user/RefreshToken.java            | 157 ++++++++++++++++++
 .../manager/setup/design/UserDesignDocument.java   |  16 ++
 .../streampipes/rest/impl/Authentication.java      | 184 +++++++++++++++++++--
 .../service/core/UnauthenticatedInterfaces.java    |   2 +
 .../core/migrations/AvailableMigrations.java       |   4 +-
 .../v099/AddRefreshTokenViewsMigration.java        |  73 ++++++++
 ...CookieOAuth2AuthorizationRequestRepository.java |  35 ++--
 .../oauth2/OAuth2AuthenticationSuccessHandler.java | 138 +++++++++++-----
 .../storage/api/core/INoSqlStorage.java            |   3 +
 .../storage/api/user/IRefreshTokenStorage.java     |  12 +-
 .../storage/couchdb/CouchDbStorageManager.java     |   7 +
 .../couchdb/impl/user/RefreshTokenStorageImpl.java |  63 +++++++
 .../management/service/RefreshTokenService.java    | 132 +++++++++++++++
 .../_guards/auth.can-activate-children.guard.ts    |  20 +--
 ui/src/app/_guards/auth.can-activate.guard.ts      |  12 +-
 .../login/components/login/login.component.html    |   3 +
 .../app/login/components/login/login.component.ts  |   8 +-
 ui/src/app/login/services/login.service.ts         |  19 ++-
 ui/src/app/services/auth.service.ts                |  94 +++++++++--
 20 files changed, 868 insertions(+), 118 deletions(-)

diff --git 
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
 
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
index 878d4d0ce9..6fe5652d6f 100644
--- 
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
+++ 
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
@@ -18,5 +18,7 @@
 
 package org.apache.streampipes.model.client.user;
 
-public record LoginRequest(String username, String password) {
+public record LoginRequest(String username,
+                           String password,
+                           boolean rememberMe) {
 }
diff --git 
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
 
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
new file mode 100644
index 0000000000..ed75082748
--- /dev/null
+++ 
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
@@ -0,0 +1,157 @@
+/*
+ * 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.streampipes.model.client.user;
+
+import org.apache.streampipes.model.shared.api.Storable;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.gson.annotations.SerializedName;
+
+public class RefreshToken implements Storable {
+
+  @SerializedName("_id")
+  private String tokenId;
+
+  @SerializedName("_rev")
+  private String rev;
+
+  // This field should be called $type since this is the identifier used in 
the CouchDB view
+  @SerializedName("$type")
+  @JsonIgnore
+  private String type = "refresh-token";
+
+  private String principalId;
+
+  @JsonIgnore
+  private String hashedToken;
+
+  private long createdAtMillis;
+  private long expiresAtMillis;
+  private Long revokedAtMillis;
+  private String replacedByTokenId;
+  private boolean rememberMe;
+
+  public RefreshToken() {
+  }
+
+  public static RefreshToken create(String tokenId,
+                                    String principalId,
+                                    String hashedToken,
+                                    long createdAtMillis,
+                                    long expiresAtMillis,
+                                    boolean rememberMe) {
+    RefreshToken token = new RefreshToken();
+    token.setTokenId(tokenId);
+    token.setPrincipalId(principalId);
+    token.setHashedToken(hashedToken);
+    token.setCreatedAtMillis(createdAtMillis);
+    token.setExpiresAtMillis(expiresAtMillis);
+    token.setRememberMe(rememberMe);
+    return token;
+  }
+
+  @Override
+  public String getElementId() {
+    return tokenId;
+  }
+
+  @Override
+  public void setElementId(String elementId) {
+    this.tokenId = elementId;
+  }
+
+  public String getTokenId() {
+    return tokenId;
+  }
+
+  public void setTokenId(String tokenId) {
+    this.tokenId = tokenId;
+  }
+
+  public String getRev() {
+    return rev;
+  }
+
+  public void setRev(String rev) {
+    this.rev = rev;
+  }
+
+  public String getType() {
+    return type;
+  }
+
+  public void setType(String type) {
+    this.type = type;
+  }
+
+  public String getPrincipalId() {
+    return principalId;
+  }
+
+  public void setPrincipalId(String principalId) {
+    this.principalId = principalId;
+  }
+
+  public String getHashedToken() {
+    return hashedToken;
+  }
+
+  public void setHashedToken(String hashedToken) {
+    this.hashedToken = hashedToken;
+  }
+
+  public long getCreatedAtMillis() {
+    return createdAtMillis;
+  }
+
+  public void setCreatedAtMillis(long createdAtMillis) {
+    this.createdAtMillis = createdAtMillis;
+  }
+
+  public long getExpiresAtMillis() {
+    return expiresAtMillis;
+  }
+
+  public void setExpiresAtMillis(long expiresAtMillis) {
+    this.expiresAtMillis = expiresAtMillis;
+  }
+
+  public Long getRevokedAtMillis() {
+    return revokedAtMillis;
+  }
+
+  public void setRevokedAtMillis(Long revokedAtMillis) {
+    this.revokedAtMillis = revokedAtMillis;
+  }
+
+  public String getReplacedByTokenId() {
+    return replacedByTokenId;
+  }
+
+  public void setReplacedByTokenId(String replacedByTokenId) {
+    this.replacedByTokenId = replacedByTokenId;
+  }
+
+  public boolean isRememberMe() {
+    return rememberMe;
+  }
+
+  public void setRememberMe(boolean rememberMe) {
+    this.rememberMe = rememberMe;
+  }
+}
diff --git 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
index 4aa7176178..2ab4659efa 100644
--- 
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
+++ 
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
@@ -37,6 +37,14 @@ public class UserDesignDocument {
   public static final String PRIVILEGE_MAP_FUNCTION =
       "function(doc) { if(doc.$type === 'privilege') { emit(doc._id, doc); } 
}";
 
+  public static final String REFRESH_TOKEN_BY_HASH_KEY = 
"refresh-token-by-hash";
+  public static final String REFRESH_TOKEN_BY_HASH_MAP_FUNCTION =
+      "function(doc) { if (doc.$type === 'refresh-token' && doc.hashedToken) { 
emit(doc.hashedToken, doc); } }";
+
+  public static final String REFRESH_TOKEN_BY_USER_KEY = 
"refresh-token-by-user";
+  public static final String REFRESH_TOKEN_BY_USER_MAP_FUNCTION =
+      "function(doc) { if (doc.$type === 'refresh-token' && doc.principalId) { 
emit(doc.principalId, doc); } }";
+
   public DesignDocument make() {
     DesignDocument userDocument = prepareDocument("_design/users");
     Map<String, DesignDocument.MapReduce> views = new HashMap<>();
@@ -81,6 +89,12 @@ public class UserDesignDocument {
     DesignDocument.MapReduce privilegeFunction = new 
DesignDocument.MapReduce();
     privilegeFunction.setMap(PRIVILEGE_MAP_FUNCTION);
 
+    DesignDocument.MapReduce refreshTokenByHashFunction = new 
DesignDocument.MapReduce();
+    refreshTokenByHashFunction.setMap(REFRESH_TOKEN_BY_HASH_MAP_FUNCTION);
+
+    DesignDocument.MapReduce refreshTokenByUserFunction = new 
DesignDocument.MapReduce();
+    refreshTokenByUserFunction.setMap(REFRESH_TOKEN_BY_USER_MAP_FUNCTION);
+
     views.put("password", passwordFunction);
     views.put(USERNAME_KEY, usernameFunction);
     views.put("groups", groupFunction);
@@ -92,6 +106,8 @@ public class UserDesignDocument {
     views.put("password-recovery", passwordRecoveryFunction);
     views.put(ROLE_KEY, roleFunction);
     views.put("privilege", privilegeFunction);
+    views.put(REFRESH_TOKEN_BY_HASH_KEY, refreshTokenByHashFunction);
+    views.put(REFRESH_TOKEN_BY_USER_KEY, refreshTokenByUserFunction);
 
     userDocument.setViews(views);
 
diff --git 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
index ac61cd1939..456a2aa9d6 100644
--- 
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
+++ 
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
@@ -27,17 +27,19 @@ import org.apache.streampipes.model.client.user.Principal;
 import org.apache.streampipes.model.client.user.UserAccount;
 import org.apache.streampipes.model.client.user.UserRegistrationData;
 import org.apache.streampipes.model.configuration.GeneralConfig;
-import org.apache.streampipes.model.message.ErrorMessage;
 import org.apache.streampipes.model.message.NotificationType;
 import org.apache.streampipes.model.message.Notifications;
 import org.apache.streampipes.model.message.SuccessMessage;
 import org.apache.streampipes.rest.core.base.impl.AbstractRestResource;
 import org.apache.streampipes.rest.shared.exception.SpMessageException;
+import org.apache.streampipes.storage.management.StorageDispatcher;
 import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
 import org.apache.streampipes.user.management.model.PrincipalUserDetails;
+import org.apache.streampipes.user.management.service.RefreshTokenService;
 
-import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
 import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
 import org.springframework.http.ResponseEntity;
 import org.springframework.security.authentication.AuthenticationManager;
 import org.springframework.security.authentication.BadCredentialsException;
@@ -50,42 +52,104 @@ import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 @RestController
 @RequestMapping("/api/v2/auth")
 public class Authentication extends AbstractRestResource {
 
-  @Autowired
+  private static final String REFRESH_TOKEN_COOKIE = "sp-refresh-token";
+  private static final String ENCODED_REFRESH_TOKEN_PREFIX = "b64.";
+  private static final long MIN_REFRESH_COOKIE_SECONDS = 1;
+
   AuthenticationManager authenticationManager;
 
+  public Authentication(AuthenticationManager authenticationManager) {
+    this.authenticationManager = authenticationManager;
+  }
+
   @PostMapping(
       path = "/login",
       produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
       consumes = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
-  public ResponseEntity<?> doLogin(@RequestBody LoginRequest login) {
+  public ResponseEntity<?> doLogin(@RequestBody LoginRequest login,
+                                   HttpServletRequest request,
+                                   HttpServletResponse response) {
     try {
       org.springframework.security.core.Authentication authentication = 
authenticationManager.authenticate(
           new UsernamePasswordAuthenticationToken(login.username(), 
login.password()));
       SecurityContextHolder.getContext().setAuthentication(authentication);
-      return processAuth(authentication);
+      return processAuth(authentication, login.rememberMe(), request, 
response);
     } catch (BadCredentialsException e) {
       return unauthorized();
     }
   }
 
-  @GetMapping(
-      path = "/token/renew",
+  @PostMapping(
+      path = "/token/refresh",
       produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
-  public ResponseEntity<?> doLogin() {
-    try {
-      org.springframework.security.core.Authentication auth = 
SecurityContextHolder.getContext().getAuthentication();
-      return processAuth(auth);
-    } catch (BadCredentialsException e) {
-      return ok(new 
ErrorMessage(NotificationType.LOGIN_FAILED.uiNotification()));
+  public ResponseEntity<?> refreshToken(HttpServletRequest request,
+                                        HttpServletResponse response) {
+    String existingToken = getRefreshTokenFromRequest(request);
+
+    if (existingToken == null) {
+      clearRefreshCookie(request, response);
+      return unauthorized();
+    }
+
+    var issuedRefreshToken = new 
RefreshTokenService().rotateRefreshToken(existingToken);
+
+    if (issuedRefreshToken == null) {
+      clearRefreshCookie(request, response);
+      return unauthorized();
+    }
+
+    var principal = StorageDispatcher.INSTANCE
+        .getNoSqlStore()
+        .getUserStorageAPI()
+        .getUserById(issuedRefreshToken.principalId());
+
+    if (!(principal instanceof UserAccount userAccount)) {
+      clearRefreshCookie(request, response);
+      return unauthorized();
     }
+
+    setRefreshCookie(request, response, issuedRefreshToken);
+
+    String jwt = new JwtTokenProvider().createToken(userAccount);
+    return ok(new JwtAuthenticationResponse(jwt));
+  }
+
+  @PostMapping(
+      path = "/logout",
+      produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
+  public ResponseEntity<?> logout(HttpServletRequest request,
+                                  HttpServletResponse response) {
+    RefreshTokenService refreshTokenService = new RefreshTokenService();
+    String existingToken = getRefreshTokenFromRequest(request);
+
+    if (existingToken != null) {
+      refreshTokenService.deleteAllRefreshTokensByRawToken(existingToken);
+    } else {
+      var authentication = 
SecurityContextHolder.getContext().getAuthentication();
+      if (authentication != null && authentication.getPrincipal() instanceof 
PrincipalUserDetails<?> principal) {
+        
refreshTokenService.deleteAllRefreshTokens(principal.getDetails().getPrincipalId());
+      }
+    }
+
+    clearRefreshCookie(request, response);
+    SecurityContextHolder.clearContext();
+
+    return ok();
   }
 
   @PostMapping(
@@ -153,10 +217,17 @@ public class Authentication extends AbstractRestResource {
     return ok(response);
   }
 
-  private ResponseEntity<JwtAuthenticationResponse> 
processAuth(org.springframework.security.core.Authentication auth) {
+  private ResponseEntity<JwtAuthenticationResponse> 
processAuth(org.springframework.security.core.Authentication auth,
+                                                                boolean 
rememberMe,
+                                                                
HttpServletRequest request,
+                                                                
HttpServletResponse response) {
     Principal principal = ((PrincipalUserDetails<?>) 
auth.getPrincipal()).getDetails();
     if (principal instanceof UserAccount) {
       JwtAuthenticationResponse tokenResp = makeJwtResponse(auth);
+      if (request != null && response != null) {
+        var issuedRefreshToken = new 
RefreshTokenService().issueRefreshToken(principal.getPrincipalId(), rememberMe);
+        setRefreshCookie(request, response, issuedRefreshToken);
+      }
       ((UserAccount) 
principal).setLastLoginAtMillis(System.currentTimeMillis());
       getSpResourceManager().manageUsers().updateUser(principal);
       return ok(tokenResp);
@@ -170,6 +241,91 @@ public class Authentication extends AbstractRestResource {
     return new JwtAuthenticationResponse(jwt);
   }
 
+  private void setRefreshCookie(HttpServletRequest request,
+                                HttpServletResponse response,
+                                RefreshTokenService.IssuedRefreshToken 
issuedRefreshToken) {
+    long maxAgeSeconds = TimeUnit.MILLISECONDS.toSeconds(
+        Math.max(
+            MIN_REFRESH_COOKIE_SECONDS,
+            issuedRefreshToken.expiresAtMillis() - System.currentTimeMillis()
+        )
+    );
+
+    ResponseCookie.ResponseCookieBuilder cookieBuilder = ResponseCookie
+        .from(REFRESH_TOKEN_COOKIE, 
encodeCookieTokenValue(issuedRefreshToken.rawToken()))
+        .httpOnly(true)
+        .secure(isSecureRequest(request))
+        .path(refreshCookiePath(request))
+        .sameSite("Lax");
+
+    if (issuedRefreshToken.rememberMe()) {
+      cookieBuilder.maxAge(maxAgeSeconds);
+    }
+
+    response.addHeader(HttpHeaders.SET_COOKIE, 
cookieBuilder.build().toString());
+  }
+
+  private void clearRefreshCookie(HttpServletRequest request,
+                                  HttpServletResponse response) {
+    ResponseCookie cookie = ResponseCookie
+        .from(REFRESH_TOKEN_COOKIE, "")
+        .httpOnly(true)
+        .secure(isSecureRequest(request))
+        .path(refreshCookiePath(request))
+        .maxAge(0)
+        .sameSite("Lax")
+        .build();
+
+    response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+  }
+
+  private String getRefreshTokenFromRequest(HttpServletRequest request) {
+    Cookie[] cookies = request.getCookies();
+
+    if (cookies == null) {
+      return null;
+    }
+
+    for (Cookie cookie : cookies) {
+      if (REFRESH_TOKEN_COOKIE.equals(cookie.getName())) {
+        return decodeCookieTokenValue(cookie.getValue());
+      }
+    }
+
+    return null;
+  }
+
+  private String refreshCookiePath(HttpServletRequest request) {
+    var contextPath = request.getContextPath();
+    return (contextPath == null ? "" : contextPath) + "/api/v2/auth";
+  }
+
+  private boolean isSecureRequest(HttpServletRequest request) {
+    String forwardedProto = request.getHeader("X-Forwarded-Proto");
+    return request.isSecure() || "https".equalsIgnoreCase(forwardedProto);
+  }
+
+  private String encodeCookieTokenValue(String rawToken) {
+    return ENCODED_REFRESH_TOKEN_PREFIX + Base64.getUrlEncoder()
+        .withoutPadding()
+        .encodeToString(rawToken.getBytes(StandardCharsets.UTF_8));
+  }
+
+  private String decodeCookieTokenValue(String cookieValue) {
+    if (!cookieValue.startsWith(ENCODED_REFRESH_TOKEN_PREFIX)) {
+      return cookieValue;
+    }
+
+    try {
+      byte[] decoded = Base64.getUrlDecoder().decode(
+          cookieValue.substring(ENCODED_REFRESH_TOKEN_PREFIX.length())
+      );
+      return new String(decoded, StandardCharsets.UTF_8);
+    } catch (IllegalArgumentException e) {
+      return null;
+    }
+  }
+
   private UiOAuthSettings makeOAuthSettings() {
     var env = Environments.getEnvironment();
     var oAuthConfigs = env.getOAuthConfigurations();
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
index 1653f93e1f..774de0c1ea 100644
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
@@ -27,8 +27,10 @@ public class UnauthenticatedInterfaces {
         "/api/svchealth/*",
         "/api/v2/setup/configured",
         "/api/v2/auth/login",
+        "/api/v2/auth/logout",
         "/api/v2/auth/register",
         "/api/v2/auth/settings",
+        "/api/v2/auth/token/refresh",
         "/api/v2/auth/restore/*",
         "/api/v2/restore-password/*",
         "/api/v2/activate-account/*",
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
index b09d4d7ec6..b981830201 100644
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
@@ -31,6 +31,7 @@ import 
org.apache.streampipes.service.core.migrations.v0980.ModifyAssetLinkTypes
 import 
org.apache.streampipes.service.core.migrations.v0980.ModifyAssetLinksMigration;
 import 
org.apache.streampipes.service.core.migrations.v099.AddAssetManagementViewMigration;
 import 
org.apache.streampipes.service.core.migrations.v099.AddFunctionStateViewMigration;
+import 
org.apache.streampipes.service.core.migrations.v099.AddRefreshTokenViewsMigration;
 import 
org.apache.streampipes.service.core.migrations.v099.AddScriptTemplateViewMigration;
 import 
org.apache.streampipes.service.core.migrations.v099.ComputeCertificateThumbprintMigration;
 import 
org.apache.streampipes.service.core.migrations.v099.CreateAssetPermissionMigration;
@@ -82,7 +83,8 @@ public class AvailableMigrations {
         new MigrateAdaptersToUseScript(),
         new ModifyAssetLinkIconMigration(),
         new RemoveDuplicatedAssetPermissions(),
-        new AddFunctionStateViewMigration()
+        new AddFunctionStateViewMigration(),
+        new AddRefreshTokenViewsMigration()
     );
   }
 }
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
new file mode 100644
index 0000000000..773f9ff653
--- /dev/null
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
@@ -0,0 +1,73 @@
+/*
+ * 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.streampipes.service.core.migrations.v099;
+
+import org.apache.streampipes.manager.setup.design.UserDesignDocument;
+import org.apache.streampipes.service.core.migrations.Migration;
+import org.apache.streampipes.storage.couchdb.utils.Utils;
+
+import org.lightcouch.DesignDocument;
+import org.lightcouch.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class AddRefreshTokenViewsMigration implements Migration {
+
+  private static final String DOC_NAME = "_design/users";
+  private static final Logger LOG = 
LoggerFactory.getLogger(AddRefreshTokenViewsMigration.class);
+
+  @Override
+  public boolean shouldExecute() {
+    var designDoc = Utils.getCouchDbUserClient().design().getFromDb(DOC_NAME);
+    var views = designDoc.getViews();
+
+    return !containsView(
+        views,
+        UserDesignDocument.REFRESH_TOKEN_BY_HASH_KEY,
+        UserDesignDocument.REFRESH_TOKEN_BY_HASH_MAP_FUNCTION
+    ) || !containsView(
+        views,
+        UserDesignDocument.REFRESH_TOKEN_BY_USER_KEY,
+        UserDesignDocument.REFRESH_TOKEN_BY_USER_MAP_FUNCTION
+    );
+  }
+
+  @Override
+  public void executeMigration() throws IOException {
+    var userDocument = new UserDesignDocument().make();
+    Response resp = 
Utils.getCouchDbUserClient().design().synchronizeWithDb(userDocument);
+
+    if (resp.getError() != null) {
+      LOG.warn("Could not update user design document with reason {}", 
resp.getReason());
+    }
+  }
+
+  @Override
+  public String getDescription() {
+    return "Add refresh token views to user database";
+  }
+
+  private boolean containsView(Map<String, DesignDocument.MapReduce> views,
+                               String viewKey,
+                               String mapFunction) {
+    return views.containsKey(viewKey) && 
mapFunction.equals(views.get(viewKey).getMap());
+  }
+}
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
index f68ef84e90..d442ddc053 100755
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
@@ -33,9 +33,10 @@ import jakarta.servlet.http.HttpServletResponse;
 public class HttpCookieOAuth2AuthorizationRequestRepository
     implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
 
-  private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = 
"oauth2_auth_request";
-  public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
-  private static final int cookieExpireSeconds = 180;
+  private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME = 
"oauth2_auth_request";
+  public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
+  public static final String REMEMBER_ME_PARAM_COOKIE_NAME = "remember_me";
+  private static final int cookieExpireSeconds = 180;
 
   @Override
   public OAuth2AuthorizationRequest 
loadAuthorizationRequest(HttpServletRequest request) {
@@ -63,11 +64,16 @@ public class HttpCookieOAuth2AuthorizationRequestRepository
         cookieExpireSeconds
     );
 
-    String redirectUriAfterLogin = 
request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
-    if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
-      CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, 
redirectUriAfterLogin, cookieExpireSeconds);
-    }
-  }
+    String redirectUriAfterLogin = 
request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
+    if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
+      CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME, 
redirectUriAfterLogin, cookieExpireSeconds);
+    }
+
+    String rememberMe = request.getParameter(REMEMBER_ME_PARAM_COOKIE_NAME);
+    if (StringUtils.isNotBlank(rememberMe)) {
+      CookieUtils.addCookie(response, REMEMBER_ME_PARAM_COOKIE_NAME, 
rememberMe, cookieExpireSeconds);
+    }
+  }
 
   @Override
   public OAuth2AuthorizationRequest 
removeAuthorizationRequest(HttpServletRequest request,
@@ -75,9 +81,10 @@ public class HttpCookieOAuth2AuthorizationRequestRepository
     return this.loadAuthorizationRequest(request);
   }
 
-  public void removeAuthorizationRequestCookies(HttpServletRequest request,
-                                                HttpServletResponse response) {
-    CookieUtils.deleteCookie(request, response, 
OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
-    CookieUtils.deleteCookie(request, response, 
REDIRECT_URI_PARAM_COOKIE_NAME);
-  }
-}
+  public void removeAuthorizationRequestCookies(HttpServletRequest request,
+                                                HttpServletResponse response) {
+    CookieUtils.deleteCookie(request, response, 
OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
+    CookieUtils.deleteCookie(request, response, 
REDIRECT_URI_PARAM_COOKIE_NAME);
+    CookieUtils.deleteCookie(request, response, REMEMBER_ME_PARAM_COOKIE_NAME);
+  }
+}
diff --git 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
index 43d058a992..36cfd187ab 100755
--- 
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
+++ 
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
@@ -18,31 +18,41 @@
 
 package org.apache.streampipes.service.core.oauth2;
 
-import org.apache.streampipes.commons.environment.Environment;
-import org.apache.streampipes.commons.environment.Environments;
-import org.apache.streampipes.rest.shared.exception.BadRequestException;
-import org.apache.streampipes.service.core.oauth2.util.CookieUtils;
-import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.core.Authentication;
-import 
org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
-import org.springframework.stereotype.Component;
+import org.apache.streampipes.commons.environment.Environment;
+import org.apache.streampipes.commons.environment.Environments;
+import org.apache.streampipes.model.client.user.Principal;
+import org.apache.streampipes.rest.shared.exception.BadRequestException;
+import org.apache.streampipes.service.core.oauth2.util.CookieUtils;
+import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
+import org.apache.streampipes.user.management.model.PrincipalUserDetails;
+import org.apache.streampipes.user.management.service.RefreshTokenService;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.security.core.Authentication;
+import 
org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
 
 import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import java.io.IOException;
-import java.net.URI;
-import java.util.Optional;
-
-import static 
org.apache.streampipes.service.core.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
-
-@Component
-public class OAuth2AuthenticationSuccessHandler extends 
SimpleUrlAuthenticationSuccessHandler {
-
-  private final JwtTokenProvider tokenProvider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class OAuth2AuthenticationSuccessHandler extends 
SimpleUrlAuthenticationSuccessHandler {
+
+  private static final String REFRESH_TOKEN_COOKIE = "sp-refresh-token";
+  private static final String ENCODED_REFRESH_TOKEN_PREFIX = "b64.";
+  private static final long MIN_REFRESH_COOKIE_SECONDS = 1;
+
+  private final JwtTokenProvider tokenProvider;
   private final HttpCookieOAuth2AuthorizationRequestRepository 
httpCookieOAuth2AuthorizationRequestRepository;
   private final Environment env;
 
@@ -69,27 +79,77 @@ public class OAuth2AuthenticationSuccessHandler extends 
SimpleUrlAuthenticationS
   }
 
   @Override
-  protected String determineTargetUrl(HttpServletRequest request,
-                                      HttpServletResponse response,
-                                      Authentication authentication) {
-    Optional<String> redirectUri = CookieUtils
-        .getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
-        .map(Cookie::getValue);
+  protected String determineTargetUrl(HttpServletRequest request,
+                                      HttpServletResponse response,
+                                      Authentication authentication) {
+    Optional<String> redirectUri = CookieUtils
+        .getCookie(request, 
HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
+        .map(Cookie::getValue);
 
     if (redirectUri.isPresent() && 
!isAuthorizedRedirectUri(redirectUri.get())) {
       throw new BadRequestException(
           "Unauthorized redirect uri found - check the redirect uri in your 
OAuth config"
       );
-    }
-
-    String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
-    String token = tokenProvider.createToken(authentication);
-
-    return targetUrl + "?token=" + token;
-  }
-
-  protected void clearAuthenticationAttributes(HttpServletRequest request,
-                                               HttpServletResponse response) {
+    }
+
+    String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
+    boolean rememberMe = CookieUtils
+        .getCookie(request, 
HttpCookieOAuth2AuthorizationRequestRepository.REMEMBER_ME_PARAM_COOKIE_NAME)
+        .map(Cookie::getValue)
+        .map(Boolean::parseBoolean)
+        .orElse(false);
+
+    Principal principal = ((PrincipalUserDetails<?>) 
authentication.getPrincipal()).getDetails();
+    var refreshToken = new 
RefreshTokenService().issueRefreshToken(principal.getPrincipalId(), rememberMe);
+    setRefreshCookie(request, response, refreshToken);
+
+    String token = tokenProvider.createToken(authentication);
+
+    return targetUrl + "?token=" + token;
+  }
+
+  private void setRefreshCookie(HttpServletRequest request,
+                                HttpServletResponse response,
+                                RefreshTokenService.IssuedRefreshToken 
issuedRefreshToken) {
+    long maxAgeSeconds = TimeUnit.MILLISECONDS.toSeconds(
+        Math.max(
+            MIN_REFRESH_COOKIE_SECONDS,
+            issuedRefreshToken.expiresAtMillis() - System.currentTimeMillis()
+        )
+    );
+
+    ResponseCookie.ResponseCookieBuilder cookieBuilder = ResponseCookie
+        .from(REFRESH_TOKEN_COOKIE, 
encodeCookieTokenValue(issuedRefreshToken.rawToken()))
+        .httpOnly(true)
+        .secure(isSecureRequest(request))
+        .path(refreshCookiePath(request))
+        .sameSite("Lax");
+
+    if (issuedRefreshToken.rememberMe()) {
+      cookieBuilder.maxAge(maxAgeSeconds);
+    }
+
+    response.addHeader(HttpHeaders.SET_COOKIE, 
cookieBuilder.build().toString());
+  }
+
+  private String refreshCookiePath(HttpServletRequest request) {
+    var contextPath = request.getContextPath();
+    return (contextPath == null ? "" : contextPath) + "/api/v2/auth";
+  }
+
+  private boolean isSecureRequest(HttpServletRequest request) {
+    String forwardedProto = request.getHeader("X-Forwarded-Proto");
+    return request.isSecure() || "https".equalsIgnoreCase(forwardedProto);
+  }
+
+  private String encodeCookieTokenValue(String rawToken) {
+    return ENCODED_REFRESH_TOKEN_PREFIX + Base64.getUrlEncoder()
+        .withoutPadding()
+        .encodeToString(rawToken.getBytes(StandardCharsets.UTF_8));
+  }
+
+  protected void clearAuthenticationAttributes(HttpServletRequest request,
+                                               HttpServletResponse response) {
     super.clearAuthenticationAttributes(request);
     
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request,
 response);
   }
diff --git 
a/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
 
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
index 2e659aa65f..3f7c1ca222 100644
--- 
a/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
+++ 
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
@@ -42,6 +42,7 @@ import 
org.apache.streampipes.storage.api.system.ITransformationScriptTemplateSt
 import org.apache.streampipes.storage.api.user.IPasswordRecoveryTokenStorage;
 import org.apache.streampipes.storage.api.user.IPermissionStorage;
 import org.apache.streampipes.storage.api.user.IPrivilegeStorage;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
 import org.apache.streampipes.storage.api.user.IRoleStorage;
 import org.apache.streampipes.storage.api.user.IUserActivationTokenStorage;
 import org.apache.streampipes.storage.api.user.IUserGroupStorage;
@@ -91,6 +92,8 @@ public interface INoSqlStorage {
 
   IUserActivationTokenStorage getUserActivationTokenStorage();
 
+  IRefreshTokenStorage getRefreshTokenStorage();
+
   IExtensionsServiceStorage getExtensionsServiceStorage();
 
   IExtensionsServiceConfigurationStorage 
getExtensionsServiceConfigurationStorage();
diff --git 
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
 
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
similarity index 67%
copy from 
streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
copy to 
streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
index 878d4d0ce9..f5373e2877 100644
--- 
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
+++ 
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
@@ -15,8 +15,16 @@
  * limitations under the License.
  *
  */
+package org.apache.streampipes.storage.api.user;
 
-package org.apache.streampipes.model.client.user;
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.core.CRUDStorage;
 
-public record LoginRequest(String username, String password) {
+import java.util.List;
+
+public interface IRefreshTokenStorage extends CRUDStorage<RefreshToken> {
+
+  RefreshToken findByHashedToken(String hashedToken);
+
+  List<RefreshToken> findByPrincipalId(String principalId);
 }
diff --git 
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
 
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
index d7d07af389..a682385ac3 100644
--- 
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
+++ 
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
@@ -44,6 +44,7 @@ import 
org.apache.streampipes.storage.api.system.ITransformationScriptTemplateSt
 import org.apache.streampipes.storage.api.user.IPasswordRecoveryTokenStorage;
 import org.apache.streampipes.storage.api.user.IPermissionStorage;
 import org.apache.streampipes.storage.api.user.IPrivilegeStorage;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
 import org.apache.streampipes.storage.api.user.IRoleStorage;
 import org.apache.streampipes.storage.api.user.IUserActivationTokenStorage;
 import org.apache.streampipes.storage.api.user.IUserGroupStorage;
@@ -74,6 +75,7 @@ import 
org.apache.streampipes.storage.couchdb.impl.system.TransformationScriptTe
 import 
org.apache.streampipes.storage.couchdb.impl.user.PasswordRecoveryTokenStorageImpl;
 import org.apache.streampipes.storage.couchdb.impl.user.PermissionStorageImpl;
 import org.apache.streampipes.storage.couchdb.impl.user.PrivilegeStorageImpl;
+import 
org.apache.streampipes.storage.couchdb.impl.user.RefreshTokenStorageImpl;
 import org.apache.streampipes.storage.couchdb.impl.user.RoleStorageImpl;
 import 
org.apache.streampipes.storage.couchdb.impl.user.UserActivationTokenStorageImpl;
 import org.apache.streampipes.storage.couchdb.impl.user.UserGroupStorageImpl;
@@ -190,6 +192,11 @@ public class CouchDbStorageManager implements 
INoSqlStorage {
     return new UserActivationTokenStorageImpl();
   }
 
+  @Override
+  public IRefreshTokenStorage getRefreshTokenStorage() {
+    return new RefreshTokenStorageImpl();
+  }
+
   @Override
   public IExtensionsServiceStorage getExtensionsServiceStorage() {
     return new ExtensionsServiceStorageImpl();
diff --git 
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
 
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
new file mode 100644
index 0000000000..1078a5b5ac
--- /dev/null
+++ 
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
@@ -0,0 +1,63 @@
+/*
+ * 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.streampipes.storage.couchdb.impl.user;
+
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
+import org.apache.streampipes.storage.couchdb.impl.core.DefaultViewCrudStorage;
+import org.apache.streampipes.storage.couchdb.utils.Utils;
+
+import java.util.List;
+
+public class RefreshTokenStorageImpl extends 
DefaultViewCrudStorage<RefreshToken>
+    implements IRefreshTokenStorage {
+
+  private static final String REFRESH_TOKEN_BY_HASH_VIEW = 
"users/refresh-token-by-hash";
+  private static final String REFRESH_TOKEN_BY_PRINCIPAL_ID_VIEW = 
"users/refresh-token-by-user";
+
+  public RefreshTokenStorageImpl() {
+    super(
+        Utils::getCouchDbUserClient,
+        RefreshToken.class,
+        REFRESH_TOKEN_BY_HASH_VIEW
+    );
+  }
+
+  @Override
+  public RefreshToken findByHashedToken(String hashedToken) {
+    return couchDbClientSupplier
+        .get()
+        .view(REFRESH_TOKEN_BY_HASH_VIEW)
+        .key(hashedToken)
+        .includeDocs(true)
+        .query(RefreshToken.class)
+        .stream()
+        .findFirst()
+        .orElse(null);
+  }
+
+  @Override
+  public List<RefreshToken> findByPrincipalId(String principalId) {
+    return couchDbClientSupplier
+        .get()
+        .view(REFRESH_TOKEN_BY_PRINCIPAL_ID_VIEW)
+        .key(principalId)
+        .includeDocs(true)
+        .query(RefreshToken.class);
+  }
+}
diff --git 
a/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
 
b/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
new file mode 100644
index 0000000000..e380ce1f88
--- /dev/null
+++ 
b/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
@@ -0,0 +1,132 @@
+/*
+ * 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.streampipes.user.management.service;
+
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
+import org.apache.streampipes.storage.management.StorageDispatcher;
+import org.apache.streampipes.user.management.util.TokenUtil;
+
+import java.util.UUID;
+
+public class RefreshTokenService {
+
+  private static final int REFRESH_TOKEN_LENGTH = 64;
+  private static final long SESSION_REFRESH_TOKEN_TTL_MILLIS = 24L * 60 * 60 * 
1000;
+  private static final long REMEMBER_ME_REFRESH_TOKEN_TTL_MILLIS = 30L * 24 * 
60 * 60 * 1000;
+
+  private final IRefreshTokenStorage refreshTokenStorage;
+
+  public RefreshTokenService() {
+    this.refreshTokenStorage = StorageDispatcher.INSTANCE
+        .getNoSqlStore()
+        .getRefreshTokenStorage();
+  }
+
+  public IssuedRefreshToken issueRefreshToken(String principalId,
+                                              boolean rememberMe) {
+    long createdAtMillis = System.currentTimeMillis();
+    long expiresAtMillis = createdAtMillis + getTokenLifetime(rememberMe);
+
+    String rawToken = TokenUtil.generateToken(REFRESH_TOKEN_LENGTH);
+    String hashedToken = TokenUtil.hashToken(rawToken);
+    String tokenId = UUID.randomUUID().toString();
+    RefreshToken refreshToken = RefreshToken.create(
+        tokenId,
+        principalId,
+        hashedToken,
+        createdAtMillis,
+        expiresAtMillis,
+        rememberMe
+    );
+
+    persistOrThrow(refreshToken);
+
+    return new IssuedRefreshToken(tokenId, principalId, rawToken, 
expiresAtMillis, rememberMe);
+  }
+
+  public IssuedRefreshToken rotateRefreshToken(String rawToken) {
+    String hashedToken = TokenUtil.hashToken(rawToken);
+    RefreshToken existingToken = 
refreshTokenStorage.findByHashedToken(hashedToken);
+
+    if (!isValid(existingToken)) {
+      return null;
+    }
+
+    if (existingToken.getPrincipalId() == null) {
+      return null;
+    }
+
+    long now = System.currentTimeMillis();
+    IssuedRefreshToken replacement = 
issueRefreshToken(existingToken.getPrincipalId(), existingToken.isRememberMe());
+
+    existingToken.setRevokedAtMillis(now);
+    existingToken.setReplacedByTokenId(replacement.tokenId());
+    refreshTokenStorage.updateElement(existingToken);
+
+    return replacement;
+  }
+
+  public void deleteAllRefreshTokens(String principalId) {
+    refreshTokenStorage
+        .findByPrincipalId(principalId)
+        .forEach(refreshTokenStorage::deleteElement);
+  }
+
+  public void deleteAllRefreshTokensByRawToken(String rawToken) {
+    String hashedToken = TokenUtil.hashToken(rawToken);
+    RefreshToken existingToken = 
refreshTokenStorage.findByHashedToken(hashedToken);
+
+    if (existingToken != null && existingToken.getPrincipalId() != null) {
+      deleteAllRefreshTokens(existingToken.getPrincipalId());
+    }
+  }
+
+  private long getTokenLifetime(boolean rememberMe) {
+    return rememberMe ? REMEMBER_ME_REFRESH_TOKEN_TTL_MILLIS : 
SESSION_REFRESH_TOKEN_TTL_MILLIS;
+  }
+
+  private boolean isValid(RefreshToken token) {
+    if (token == null) {
+      return false;
+    }
+
+    if (token.getRevokedAtMillis() != null) {
+      return false;
+    }
+
+    return token.getExpiresAtMillis() > System.currentTimeMillis();
+  }
+
+  private void persistOrThrow(RefreshToken refreshToken) {
+    var persistResult = refreshTokenStorage.persist(refreshToken);
+
+    if (!persistResult.k) {
+      throw new IllegalStateException(
+          String.format("Could not persist refresh token for principal '%s'", 
refreshToken.getPrincipalId())
+      );
+    }
+  }
+
+  public record IssuedRefreshToken(String tokenId,
+                                   String principalId,
+                                   String rawToken,
+                                   long expiresAtMillis,
+                                   boolean rememberMe) {
+  }
+}
diff --git a/ui/src/app/_guards/auth.can-activate-children.guard.ts 
b/ui/src/app/_guards/auth.can-activate-children.guard.ts
index 9ac4788e09..16bad5377e 100644
--- a/ui/src/app/_guards/auth.can-activate-children.guard.ts
+++ b/ui/src/app/_guards/auth.can-activate-children.guard.ts
@@ -20,30 +20,20 @@ import { Injectable } from '@angular/core';
 import {
     ActivatedRouteSnapshot,
     CanActivateChild,
-    Router,
+    GuardResult,
+    MaybeAsync,
     RouterStateSnapshot,
 } from '@angular/router';
 import { AuthService } from '../services/auth.service';
 
 @Injectable({ providedIn: 'root' })
 export class AuthCanActivateChildrenGuard implements CanActivateChild {
-    constructor(
-        private authService: AuthService,
-        private router: Router,
-    ) {}
+    constructor(private authService: AuthService) {}
 
     canActivateChild(
         childRoute: ActivatedRouteSnapshot,
         state: RouterStateSnapshot,
-    ): boolean {
-        if (this.authService.authenticated()) {
-            return true;
-        }
-        this.authService.logout();
-        this.router.navigate(['/login'], {
-            queryParams: { returnUrl: state.url },
-        });
-
-        return false;
+    ): MaybeAsync<GuardResult> {
+        return this.authService.ensureAuthenticated(state.url);
     }
 }
diff --git a/ui/src/app/_guards/auth.can-activate.guard.ts 
b/ui/src/app/_guards/auth.can-activate.guard.ts
index 9af91a2acd..bca529dbdc 100644
--- a/ui/src/app/_guards/auth.can-activate.guard.ts
+++ b/ui/src/app/_guards/auth.can-activate.guard.ts
@@ -23,27 +23,17 @@ import {
     CanActivate,
     GuardResult,
     MaybeAsync,
-    Router,
     RouterStateSnapshot,
 } from '@angular/router';
 
 @Injectable({ providedIn: 'root' })
 export class AuthCanActivateGuard implements CanActivate {
     private authService = inject(AuthService);
-    private router = inject(Router);
 
     canActivate(
         route: ActivatedRouteSnapshot,
         state: RouterStateSnapshot,
     ): MaybeAsync<GuardResult> {
-        if (this.authService.authenticated()) {
-            return true;
-        }
-        this.authService.logout();
-        this.router.navigate(['/login'], {
-            queryParams: { returnUrl: state.url },
-        });
-
-        return false;
+        return this.authService.ensureAuthenticated(state.url);
     }
 }
diff --git a/ui/src/app/login/components/login/login.component.html 
b/ui/src/app/login/components/login/login.component.html
index 65f1ae1248..eb1c81c70c 100644
--- a/ui/src/app/login/components/login/login.component.html
+++ b/ui/src/app/login/components/login/login.component.html
@@ -48,6 +48,9 @@
                         />
                     </mat-form-field>
                 </sp-form-field>
+                <mat-checkbox formControlName="rememberMe" class="mt-10">
+                    {{ 'Remember me' | translate }}
+                </mat-checkbox>
                 <div class="form-actions mt-20">
                     <button
                         mat-flat-button
diff --git a/ui/src/app/login/components/login/login.component.ts 
b/ui/src/app/login/components/login/login.component.ts
index c59e3c73b6..6d7a665b9a 100644
--- a/ui/src/app/login/components/login/login.component.ts
+++ b/ui/src/app/login/components/login/login.component.ts
@@ -41,6 +41,7 @@ import {
 import { MatFormField } from '@angular/material/form-field';
 import { MatInput } from '@angular/material/input';
 import { MatButton } from '@angular/material/button';
+import { MatCheckbox } from '@angular/material/checkbox';
 import { MatProgressSpinner } from '@angular/material/progress-spinner';
 import { TranslatePipe } from '@ngx-translate/core';
 
@@ -59,6 +60,7 @@ import { TranslatePipe } from '@ngx-translate/core';
         MatFormField,
         MatInput,
         MatButton,
+        MatCheckbox,
         MatProgressSpinner,
         SpAlertBannerComponent,
         RouterLink,
@@ -116,15 +118,19 @@ export class LoginComponent extends 
BaseLoginPageDirective {
             'password',
             new UntypedFormControl('', Validators.required),
         );
+        this.parentForm.addControl('rememberMe', new 
UntypedFormControl(false));
 
         this.parentForm.valueChanges.subscribe(v => {
             this.credentials.username = v.username;
             this.credentials.password = v.password;
+            this.credentials.rememberMe = v.rememberMe;
         });
+        this.credentials.rememberMe = false;
         this.returnUrl = this.route.snapshot.queryParams.returnUrl || '';
     }
 
     doOAuthLogin(provider: string): void {
-        window.location.href = 
`/streampipes-backend/oauth2/authorization/${provider}?redirect_uri=${this.loginSettings.oAuthSettings.redirectUri}/%23/login`;
+        const rememberMe = !!this.parentForm?.get('rememberMe')?.value;
+        window.location.href = 
`/streampipes-backend/oauth2/authorization/${provider}?redirect_uri=${this.loginSettings.oAuthSettings.redirectUri}/%23/login&remember_me=${rememberMe}`;
     }
 }
diff --git a/ui/src/app/login/services/login.service.ts 
b/ui/src/app/login/services/login.service.ts
index f0ab3144ea..fab0c0c03a 100644
--- a/ui/src/app/login/services/login.service.ts
+++ b/ui/src/app/login/services/login.service.ts
@@ -51,11 +51,24 @@ export class LoginService {
         );
     }
 
-    renewToken(): Observable<any> {
-        return this.http.get(
-            this.platformServicesCommons.apiBasePath + '/auth/token/renew',
+    refreshToken(): Observable<any> {
+        return this.http.post(
+            this.platformServicesCommons.apiBasePath + '/auth/token/refresh',
+            {},
+            {
+                context: new HttpContext().set(NGX_LOADING_BAR_IGNORED, true),
+                withCredentials: true,
+            },
+        );
+    }
+
+    logout(): Observable<any> {
+        return this.http.post(
+            this.platformServicesCommons.apiBasePath + '/auth/logout',
+            {},
             {
                 context: new HttpContext().set(NGX_LOADING_BAR_IGNORED, true),
+                withCredentials: true,
             },
         );
     }
diff --git a/ui/src/app/services/auth.service.ts 
b/ui/src/app/services/auth.service.ts
index a3da9a4fdb..eab8325681 100644
--- a/ui/src/app/services/auth.service.ts
+++ b/ui/src/app/services/auth.service.ts
@@ -18,9 +18,17 @@
 
 import { RestApi } from './rest-api.service';
 import { Injectable } from '@angular/core';
-import { Observable, timer } from 'rxjs';
+import { Observable, of, timer } from 'rxjs';
 import { JwtHelperService } from '@auth0/angular-jwt';
-import { filter, map, switchMap } from 'rxjs/operators';
+import {
+    catchError,
+    filter,
+    finalize,
+    map,
+    shareReplay,
+    switchMap,
+    tap,
+} from 'rxjs/operators';
 import { Router } from '@angular/router';
 import { LoginService } from '../login/services/login.service';
 import {
@@ -30,6 +38,8 @@ import {
 
 @Injectable({ providedIn: 'root' })
 export class AuthService {
+    private refreshInFlight$?: Observable<boolean>;
+
     constructor(
         private restApi: RestApi,
         private tokenStorage: JwtTokenStorageService,
@@ -37,12 +47,12 @@ export class AuthService {
         private router: Router,
         private loginService: LoginService,
     ) {
-        if (this.authenticated()) {
+        if (this.authenticated() && tokenStorage.getUser()) {
             this.currentUserService.authToken$.next(tokenStorage.getToken());
             this.currentUserService.user$.next(tokenStorage.getUser());
             this.currentUserService.isLoggedIn$.next(true);
         } else {
-            this.logout();
+            this.clearLocalAuthState();
         }
         this.scheduleTokenRenew();
         this.watchTokenExpiration();
@@ -55,6 +65,7 @@ export class AuthService {
         this.tokenStorage.saveUser(decodedToken.user);
         this.currentUserService.authToken$.next(data.accessToken);
         this.currentUserService.user$.next(decodedToken.user);
+        this.currentUserService.isLoggedIn$.next(true);
     }
 
     public oauthLogin(token: string) {
@@ -64,11 +75,30 @@ export class AuthService {
         this.tokenStorage.saveUser(decodedToken.user);
         this.currentUserService.authToken$.next(token);
         this.currentUserService.user$.next(decodedToken.user);
+        this.currentUserService.isLoggedIn$.next(true);
     }
 
     public logout() {
-        this.tokenStorage.clearTokens();
-        this.currentUserService.authToken$.next(undefined);
+        this.loginService.logout().subscribe({
+            next: () => this.clearLocalAuthState(),
+            error: () => this.clearLocalAuthState(),
+        });
+    }
+
+    public ensureAuthenticated(returnUrl: string): Observable<boolean> {
+        if (this.authenticated()) {
+            return of(true);
+        }
+
+        return this.refreshAccessToken().pipe(
+            tap(authenticated => {
+                if (!authenticated) {
+                    this.router.navigate(['/login'], {
+                        queryParams: { returnUrl },
+                    });
+                }
+            }),
+        );
     }
 
     public authenticated(): boolean {
@@ -84,11 +114,6 @@ export class AuthService {
         );
     }
 
-    public decodeJwtToken(token: string): any {
-        const jwtHelper: JwtHelperService = new JwtHelperService({});
-        return jwtHelper.decodeToken(token);
-    }
-
     checkConfiguration(): Observable<boolean> {
         return Observable.create(observer =>
             this.restApi.configured().subscribe(
@@ -113,21 +138,22 @@ export class AuthService {
                 map((token: any) =>
                     new JwtHelperService({}).getTokenExpirationDate(token),
                 ),
+                filter(
+                    (expiresIn: Date | null): expiresIn is Date => !!expiresIn,
+                ),
                 switchMap((expiresIn: Date) =>
                     timer(expiresIn.getTime() - Date.now() - 60000),
                 ),
             )
             .subscribe(() => {
-                if (this.authenticated()) {
+                if (this.currentUserService.authToken$.getValue()) {
                     this.updateTokenAndUserInfo();
                 }
             });
     }
 
     updateTokenAndUserInfo() {
-        this.loginService.renewToken().subscribe(data => {
-            this.login(data);
-        });
+        this.refreshAccessToken().subscribe();
     }
 
     watchTokenExpiration() {
@@ -137,16 +163,50 @@ export class AuthService {
                 map((token: any) =>
                     new JwtHelperService({}).getTokenExpirationDate(token),
                 ),
+                filter(
+                    (expiresIn: Date | null): expiresIn is Date => !!expiresIn,
+                ),
                 switchMap((expiresIn: Date) =>
                     timer(expiresIn.getTime() - Date.now() + 1),
                 ),
             )
             .subscribe(() => {
-                this.logout();
-                this.router.navigate(['login']);
+                this.refreshAccessToken().subscribe(authenticated => {
+                    if (!authenticated) {
+                        this.router.navigate(['login']);
+                    }
+                });
             });
     }
 
+    private refreshAccessToken(): Observable<boolean> {
+        if (this.refreshInFlight$) {
+            return this.refreshInFlight$;
+        }
+
+        this.refreshInFlight$ = this.loginService.refreshToken().pipe(
+            tap(data => this.login(data)),
+            map(() => true),
+            catchError(() => {
+                this.clearLocalAuthState();
+                return of(false);
+            }),
+            finalize(() => {
+                this.refreshInFlight$ = undefined;
+            }),
+            shareReplay({ bufferSize: 1, refCount: true }),
+        );
+
+        return this.refreshInFlight$;
+    }
+
+    private clearLocalAuthState() {
+        this.tokenStorage.clearTokens();
+        this.currentUserService.authToken$.next(undefined);
+        this.currentUserService.user$.next(undefined);
+        this.currentUserService.isLoggedIn$.next(false);
+    }
+
     public hasRole(role: string): boolean {
         return this.currentUserService.hasRole(role);
     }

Reply via email to