Repository: ls1intum/Artemis
Pull request #10830: `Development`: Rotate passkey tokens silently
Author: login: florian-glombik
Labels: name: tests
name: ready for review
name: server
description: Pull requests that update Java code. (Added Automatically!)
name: config-change
description: Pull requests that change the config in a way that they require a deployment via Ansible.
name: core
description: Pull requests that affect the corresponding module
Created: 2025-05-09T18:57:39Z | Merged: 2025-05-22T20:18:47Z

### Checklist
#### General
- [x] I tested **all** changes and their related features with **all** corresponding user types on a test server and locally.
- [x] I chose a title conforming to the [naming conventions for pull requests](https://docs.artemis.cit.tum.de/dev/development-process/development-process.html#naming-conventions-for-github-pull-requests).


#### Server
- [x] **Important**: I implemented the changes with a [very good performance](https://docs.artemis.cit.tum.de/dev/guidelines/performance/) and prevented too many (unnecessary) and too complex database calls.
- [x] I **strictly** followed the [server coding and design guidelines](https://docs.artemis.cit.tum.de/dev/guidelines/server/).
- [x] I added multiple integration tests (Spring) related to the features (with a high test coverage).
- [x] I documented the Java code using JavaDoc style.


### Motivation and Context
We assume passkey authentication to be more secure than password authentication and therefore want to allow users who authenticate with passkey to login less often (which means a longer lifetime for the issues jwt token).

As we plan to configure a quite long lifetime for our JwtTokens (180 days) this would mean that it is quite problematic if the token is stolen (which we assume not to be possible programmatically with the jwt being stored in a cookie in the client side and transferred via secure connections, HTTPS, only).

To reduce the attack surface here, we introduce a rolling token mechanism together with the option to define a longer lifetime for passkey tokens.

### Description
* Adds a configuration value for the max token lifetime of a JWT created on passkey login
* Adds a rolling token mechanism
* Adds integration tests for the rolling token mechanism

#### Rolling Token Mechanism
1. **Maximum Lifetime**:  
   The token has a maximum lifetime which is the same as the usual JWT (= can be configured with `jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me`, default is 30 days)

2. **Token Lifetime Check**:  
   On every request, the remaining token lifetime is checked.

3. **Token Renewal**:
    - If the remaining lifetime is below 50%, the token is renewed and reset. (which means being active at least all 15 days with the default configuration)
    - The renewal can extend the token's validity up to a maximum of 180 days, while each token itself will not be valid for longer than 30 days. (180 days this is the default value, can be configured with `artemis.user-management.passkey.token-validity-in-seconds-for-passkey`)

4. **IssuedAt (iat) Timestamp**:
    - The token must include an `issuedAt` (iat) timestamp.
    - During renewal, the `issuedAt` timestamp remains unchanged.

5. **Automatic Logout**:
    - Users who are inactive for 30 days are automatically logged out, the token cannot be renewed after its expiration.

6. **Extended Login for Active Users**:
    - Users who are active at least every 15 days and logged in with a passkey remain logged in for up to 180 days.

**Security Considerations**:
    - While a stolen token could also be renewed, this mechanism reduces the risk of stealing long-lived tokens on inactive users
    - We do rely on the cookie with the jwt token to be stored securely on the browser and the jwt only being transported on a secure channel (https) for this to work properly
    - As of now, no token revocation mechanism is planned

### Steps for Testing
Prerequisites:
- 1 Instructor
- 2 Students
- 1 Programming Exercise with Complaints enabled

#### Testserver
1. Log in to Artemis
2. Verify the login works properly 
3. Logout and Login again
4. Verify it works as expected

#### Locally
* Adjust the values `token-validity-in-seconds` (30) and `token-validity-in-seconds-for-remember-me` (60) in `application-dev.yml`
* Adjust `token-validity-in-seconds-for-passkey` in `application-core.yml` (120)

1. Reduce the value of `jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me` to a small value (e.g. 60 seconds)
2. Reduce the value of `artemis.user-management.passkey.token-validity-in-seconds-for-passkey` to a small value (e.g. 180 seconds)
3. Log out of Artemis (ensured your current jwt is cleared)
4. Log in to artemis with a passkey
5. Inspect your cookies (you find cookies in your dev tools via Application < Cookies in Chrome dev tools) and all values are set properly (e.g. with a tool like https://jwt.io/ ) (expiration should be set accordingly to you configured values)
6. Reload (page reload) Artemis after 50% of the lifetime 
7. Verify the token expiration was updated properly
8. Logout and login with username and password
9. Wait 50% of the token lifetime, reload the page
10. Verify the token was not rotated

### Testserver States
You can manage test servers using [Helios](https://helios.aet.cit.tum.de/). Check environment statuses in the [environment list](https://helios.aet.cit.tum.de/repo/69562331/environment/list). To deploy to a test server, go to the [CI/CD](https://helios.aet.cit.tum.de/repo/69562331/ci-cd) page, find your PR or branch, and trigger the deployment.

### Review Progress


#### Performance Review
#### Code Review
- [x] Code Review 1
- [x] Code Review 2
#### Manual Tests
- [x] Test 1
- [x] Test 2


### Screenshots

#### passkey

https://github.com/user-attachments/assets/960e0c21-57a2-4081-a5e5-3e42c0e87d05


#### password

https://github.com/user-attachments/assets/bef1e278-f761-47fd-a322-347c42369c92


<!-- This is an auto-generated comment: release notes by coderabbit.ai -->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
  - Added support for passkey-based authentication with dedicated JWT claims and configurable token validity.
  - Implemented silent JWT token rotation for passkey-authenticated users, enhancing session security and usability.
  - Introduced new JWT claims to distinguish passkey authentication and tool-specific tokens.
  - Added new authentication method enum to classify authentication types (PASSKEY, PASSWORD, SAML2).

- **Bug Fixes**
  - Improved JWT extraction and validation logic to handle different authentication sources more robustly.

- **Tests**
  - Added comprehensive integration and unit tests for token rotation, passkey authentication, and tool token claims.
  - Introduced utility classes to streamline authentication and cookie parsing in tests.

- **Refactor**
  - Updated constructors and methods to support new JWT handling features.
  - Standardized authentication object creation in tests for consistency.

- **Chores**
  - Removed unused notification service dependency from account-related components.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->


Diff:
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java
index b37293ee9f20..9bb1d891d217 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java
@@ -5,6 +5,8 @@
 import java.util.List;
 import java.util.Optional;
 
+import jakarta.annotation.PostConstruct;
+
 import org.springframework.beans.factory.annotation.Qualifier;
 import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
@@ -37,6 +39,7 @@
 import de.tum.cit.aet.artemis.core.security.Role;
 import de.tum.cit.aet.artemis.core.security.filter.SpaWebFilter;
 import de.tum.cit.aet.artemis.core.security.jwt.JWTConfigurer;
+import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
 import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider;
 import de.tum.cit.aet.artemis.core.security.passkey.ArtemisPasskeyWebAuthnConfigurer;
 import de.tum.cit.aet.artemis.core.service.ProfileService;
@@ -55,23 +58,46 @@ public class SecurityConfiguration {
 
     private final ArtemisPasskeyWebAuthnConfigurer passkeyWebAuthnConfigurer;
 
+    private final JWTCookieService jwtCookieService;
+
     private final PasswordService passwordService;
 
     private final ProfileService profileService;
 
     private final TokenProvider tokenProvider;
 
+    @Value("${artemis.user-management.passkey.token-validity-in-seconds-for-passkey:15552000}")
+    private long tokenValidityInSecondsForPasskey;
+
+    @Value("${" + Constants.PASSKEY_ENABLED_PROPERTY_NAME + ":false}")
+    private boolean passkeyEnabled;
+
     @Value("#{'${spring.prometheus.monitoringIp:127.0.0.1}'.split(',')}")
     private List<String> monitoringIpAddresses;
 
+    /**
+     * Validates the configuration of the validity duration of passkey generated jwts
+     *
+     * @throws IllegalStateException if the server URL configuration is invalid
+     */
+    @PostConstruct
+    public void validatePasskeyJwtValidityConfiguration() {
+        if (passkeyEnabled) {
+            if (tokenValidityInSecondsForPasskey <= 0) {
+                throw new IllegalStateException("Token validity in seconds for passkey must be greater than 0 when passkey authentication is enabled.");
+            }
+        }
+    }
+
     public SecurityConfiguration(CorsFilter corsFilter, Optional<CustomLti13Configurer> customLti13Configurer, ArtemisPasskeyWebAuthnConfigurer passkeyWebAuthnConfigurer,
-            PasswordService passwordService, ProfileService profileService, TokenProvider tokenProvider) {
+            PasswordService passwordService, ProfileService profileService, TokenProvider tokenProvider, JWTCookieService jwtCookieService) {
         this.corsFilter = corsFilter;
         this.customLti13Configurer = customLti13Configurer;
         this.passkeyWebAuthnConfigurer = passkeyWebAuthnConfigurer;
         this.passwordService = passwordService;
         this.profileService = profileService;
         this.tokenProvider = tokenProvider;
+        this.jwtCookieService = jwtCookieService;
     }
 
     /**
@@ -250,6 +276,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http, SecurityProble
      * @return JWTConfigurer configured with a token provider that generates and validates JWT tokens.
      */
     private JWTConfigurer securityConfigurerAdapter() {
-        return new JWTConfigurer(tokenProvider);
+        return new JWTConfigurer(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey);
     }
 }
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java
index af68c9c29a71..cd9b3b40bedc 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java
@@ -61,6 +61,7 @@
 import de.tum.cit.aet.artemis.core.exception.EntityNotFoundException;
 import de.tum.cit.aet.artemis.core.security.Role;
 import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter;
+import de.tum.cit.aet.artemis.core.security.jwt.JwtWithSource;
 import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider;
 import de.tum.cit.aet.artemis.core.service.AuthorizationCheckService;
 import de.tum.cit.aet.artemis.exam.api.ExamRepositoryApi;
@@ -213,7 +214,9 @@ public boolean beforeHandshake(@NotNull ServerHttpRequest request, @NotNull Serv
                 if (request instanceof ServletServerHttpRequest servletRequest) {
                     try {
                         attributes.put(IP_ADDRESS, servletRequest.getRemoteAddress());
-                        return JWTFilter.extractValidJwt(servletRequest.getServletRequest(), tokenProvider) != null;
+
+                        JwtWithSource jwtWithSource = JWTFilter.extractValidJwt(servletRequest.getServletRequest(), tokenProvider);
+                        return jwtWithSource != null;
                     }
                     catch (IllegalArgumentException e) {
                         response.setStatusCode(HttpStatusCode.valueOf(400));
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java
index 7832875b8e12..56ad8c0f3770 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java
@@ -14,6 +14,7 @@
 import org.springframework.web.servlet.HandlerInterceptor;
 
 import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter;
+import de.tum.cit.aet.artemis.core.security.jwt.JwtWithSource;
 import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider;
 
 @Profile(PROFILE_CORE)
@@ -28,9 +29,12 @@ public ToolsInterceptor(TokenProvider tokenProvider) {
 
     @Override
     public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
-        String jwtToken;
+        String jwtToken = null;
         try {
-            jwtToken = JWTFilter.extractValidJwt(request, tokenProvider);
+            JwtWithSource jwtWithSource = JWTFilter.extractValidJwt(request, tokenProvider);
+            if (jwtWithSource != null) {
+                jwtToken = jwtWithSource.jwt();
+            }
         }
         catch (IllegalArgumentException e) {
             response.sendError(HttpServletResponse.SC_BAD_REQUEST);
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java
new file mode 100644
index 000000000000..2ed18b4d54aa
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java
@@ -0,0 +1,52 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+
+public enum AuthenticationMethod {
+
+    PASSKEY("passkey"), PASSWORD("password"), SAML2("saml2");
+
+    private final String method;
+
+    AuthenticationMethod(String method) {
+        this.method = method;
+    }
+
+    public String getMethod() {
+        return method;
+    }
+
+    /**
+     * Returns the authentication method based on the provided Authentication object.
+     *
+     * @param authentication the Authentication object
+     * @return the corresponding AuthenticationMethod, or null if not found
+     */
+    public static AuthenticationMethod fromAuthentication(Authentication authentication) {
+        return switch (authentication) {
+            case WebAuthnAuthentication webAuthnAuthentication -> AuthenticationMethod.PASSKEY;
+            case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> AuthenticationMethod.PASSWORD;
+            case Saml2Authentication saml2Authentication -> AuthenticationMethod.SAML2;
+            case null, default -> null;
+        };
+    }
+
+    /**
+     * Returns the authentication method based on the provided method string.
+     *
+     * @param method the method string
+     * @return the corresponding AuthenticationMethod
+     * @throws IllegalArgumentException if the method is not recognized
+     */
+    public static AuthenticationMethod fromMethod(String method) {
+        for (AuthenticationMethod authMethod : values()) {
+            if (authMethod.method.equalsIgnoreCase(method)) {
+                return authMethod;
+            }
+        }
+        throw new IllegalArgumentException("Unknown authentication method: " + method);
+    }
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java
index 18f4849dc637..ef53c1c26556 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java
@@ -13,13 +13,19 @@ public class JWTConfigurer extends SecurityConfigurerAdapter<DefaultSecurityFilt
 
     private final TokenProvider tokenProvider;
 
+    private final JWTCookieService jwtCookieService;
+
+    private final long tokenValidityInSecondsForPasskey;
+
     /**
      * Constructs a JWTConfigurer with a specified token provider.
      *
      * @param tokenProvider the provider responsible for generating and validating JWT tokens.
      */
-    public JWTConfigurer(TokenProvider tokenProvider) {
+    public JWTConfigurer(TokenProvider tokenProvider, JWTCookieService jwtCookieService, long tokenValidityInSecondsForPasskey) {
         this.tokenProvider = tokenProvider;
+        this.jwtCookieService = jwtCookieService;
+        this.tokenValidityInSecondsForPasskey = tokenValidityInSecondsForPasskey;
     }
 
     /**
@@ -31,7 +37,7 @@ public JWTConfigurer(TokenProvider tokenProvider) {
      */
     @Override
     public void configure(HttpSecurity http) {
-        JWTFilter customFilter = new JWTFilter(tokenProvider);
+        JWTFilter customFilter = new JWTFilter(tokenProvider, jwtCookieService, tokenValidityInSecondsForPasskey);
         // Adds the JWTFilter to the security chain before the UsernamePasswordAuthenticationFilter.
         // This ensures that the JWTFilter processes the request first to extract and validate JWTs.
         http.addFilterBefore(customFilter, UsernamePasswordAuthenticationFilter.class);
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java
index 65faff636f45..0fb1307afd21 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java
@@ -64,6 +64,17 @@ public ResponseCookie buildLoginCookie(long duration, ToolTokenType tool) {
         return buildJWTCookie(jwt, Duration.of(duration, ChronoUnit.MILLIS));
     }
 
+    /**
+     * Builds the cookie containing the jwt for a login
+     *
+     * @param rotatedJwtToken        with the updated values
+     * @param durationInMilliseconds of the cookie in milliseconds and the jwt
+     * @return the login {@link ResponseCookie} containing the JWT
+     */
+    public ResponseCookie buildRotatedCookie(String rotatedJwtToken, long durationInMilliseconds) {
+        return buildJWTCookie(rotatedJwtToken, Duration.of(durationInMilliseconds, ChronoUnit.MILLIS));
+    }
+
     /**
      * Builds the cookie containing the jwt for a logout and sets it in the response
      *
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
index d83fd44615b6..e4861e1733a0 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
@@ -1,6 +1,8 @@
 package de.tum.cit.aet.artemis.core.security.jwt;
 
 import java.io.IOException;
+import java.util.Date;
+import java.util.Objects;
 
 import jakarta.annotation.Nullable;
 import jakarta.servlet.FilterChain;
@@ -10,7 +12,10 @@
 import jakarta.servlet.http.Cookie;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
+import jakarta.ws.rs.NotAuthorizedException;
 
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
 import org.springframework.security.core.Authentication;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.util.StringUtils;
@@ -30,28 +35,128 @@ public class JWTFilter extends GenericFilterBean {
 
     private final TokenProvider tokenProvider;
 
-    public JWTFilter(TokenProvider tokenProvider) {
+    private final JWTCookieService jwtCookieService;
+
+    private final long tokenValidityInSecondsForPasskey;
+
+    public JWTFilter(TokenProvider tokenProvider, JWTCookieService jwtCookieService, long tokenValidityInSecondsForPasskey) {
         this.tokenProvider = tokenProvider;
+        this.jwtCookieService = jwtCookieService;
+        this.tokenValidityInSecondsForPasskey = tokenValidityInSecondsForPasskey;
     }
 
+    /**
+     * Renews the JWT token if its remaining lifetime is less than 50% of its total validity period.
+     * The renewed token retains the original issuedAt timestamp.
+     *
+     * <p>
+     * <b>Configurable values:</b>
+     * </p>
+     * <ul>
+     * <li>{@code artemis.user-management.passkey.token-validity-in-seconds-for-passkey}: Caps the maximum lifetime of a renewed token, limiting how long an infrequently used
+     * token can remain valid.</li>
+     * <li>{@code jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}: Determines the expiration time for authentication tokens.</li>
+     * </ul>
+     *
+     * <p>
+     * <b>Security:</b>
+     * </p>
+     * <p>
+     * This mechanism relies on secure cookie storage and HTTPS. Note that no token revocation
+     * mechanism is implemented. Role changes are not checked, as resources for non-admin roles
+     * will validate the current role during access.
+     * </p>
+     *
+     * @param jwtToken       The current JWT token to evaluate for renewal.
+     * @param authentication The {@link org.springframework.security.core.Authentication} object associated with the token.
+     * @param response       The {@link jakarta.servlet.http.HttpServletResponse} where the renewed token will be added as a cookie.
+     * @throws NotAuthorizedException If the token cannot be renewed due to validation or other issues.
+     */
+    private void rotateTokenSilently(String jwtToken, Authentication authentication, HttpServletResponse response) throws NotAuthorizedException {
+        // Only rotate tokens that were issued using the PASSKEY authentication method
+        boolean canRefreshToken = Objects.equals(this.tokenProvider.getAuthenticationMethod(jwtToken), AuthenticationMethod.PASSKEY);
+        if (!canRefreshToken) {
+            return;
+        }
+
+        // Extract issued and expiration timestamps from the existing token
+        Date issuedAt = this.tokenProvider.getIssuedAtDate(jwtToken);
+        Date expirationDate = this.tokenProvider.getExpirationDate(jwtToken);
+
+        // Calculate remaining lifetime of the token
+        long nowInMs = System.currentTimeMillis();
+        long tokenValidityInMs = this.tokenProvider.getTokenValidity(true);
+        long remainingLifetime = expirationDate.getTime() - nowInMs;
+
+        // Trigger rotation if token has less than half of its validity period remaining
+        boolean isRemainingLifetimeBelowHalf = remainingLifetime < tokenValidityInMs / 2;
+        if (isRemainingLifetimeBelowHalf) {
+            // Compute the new expiration time, respecting the original token's max lifetime
+            long newTokenExpirationTimeInMs = Math.min(nowInMs + tokenValidityInMs, issuedAt.getTime() + Math.multiplyExact(this.tokenValidityInSecondsForPasskey, 1000));
+            // Determine the lifetime of the rotated token
+            long rotatedTokenDurationInMs = newTokenExpirationTimeInMs - nowInMs;
+            // Create the rotated token with updated expiration and same issued time/tools
+            var rotatedToken = this.tokenProvider.createToken(authentication, issuedAt, new Date(newTokenExpirationTimeInMs), this.tokenProvider.getTools(jwtToken), true);
+
+            // Build and set the new token as a response cookie
+            ResponseCookie responseCookie = jwtCookieService.buildRotatedCookie(rotatedToken, rotatedTokenDurationInMs);
+            response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
+        }
+    }
+
+    /**
+     * Filter that extracts a JWT from the incoming HTTP request, performs authentication,
+     * optionally rotates the token if it meets security criteria, and sets the authentication
+     * context for downstream processing.
+     *
+     * <p>
+     * If a valid token is extracted from a cookie and does not contain tool metadata,
+     * it is silently rotated and re-issued via a response cookie to extend the session securely.
+     * </p>
+     *
+     * @param servletRequest  the incoming request
+     * @param servletResponse the outgoing response
+     * @param filterChain     the remaining filter chain
+     * @throws IOException      in case of I/O errors
+     * @throws ServletException in case of filter chain issues
+     */
     @Override
     public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
         HttpServletRequest httpServletRequest = (HttpServletRequest) servletRequest;
         HttpServletResponse httpServletResponse = (HttpServletResponse) servletResponse;
-        String jwtToken;
+        String jwtToken = null;
+        String source = null;
         try {
-            jwtToken = extractValidJwt(httpServletRequest, this.tokenProvider);
+            // Extract a valid JWT (if any) and track its source (e.g., header, cookie)
+            JwtWithSource jwtWithSource = extractValidJwt(httpServletRequest, this.tokenProvider);
+            if (jwtWithSource != null) {
+                jwtToken = jwtWithSource.jwt();
+                source = jwtWithSource.source();
+            }
         }
         catch (IllegalArgumentException e) {
+            // Send a 400 response if the JWT is malformed
             httpServletResponse.sendError(HttpServletResponse.SC_BAD_REQUEST);
             return;
         }
 
         if (jwtToken != null) {
+            // Resolve the Spring Security authentication from the JWT
             Authentication authentication = this.tokenProvider.getAuthentication(jwtToken);
-            SecurityContextHolder.getContext().setAuthentication(authentication);
+
+            // Only consider rotating secure, cookie-based tokens without tool-specific data
+            boolean tokenIsConsideredSecure = "cookie".equals(source) && this.tokenProvider.getTools(jwtToken) == null;
+            if (tokenIsConsideredSecure && authentication != null) {
+                rotateTokenSilently(jwtToken, authentication, httpServletResponse);
+            }
+
+            // Set the security context if authentication succeeded
+            if (authentication != null) {
+                SecurityContextHolder.getContext().setAuthentication(authentication);
+            }
         }
 
+        // Continue with the remaining filters in the chain
         filterChain.doFilter(servletRequest, servletResponse);
     }
 
@@ -63,7 +168,7 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
      * @return the valid jwt or null if not found or invalid
      */
     @Nullable
-    public static String extractValidJwt(HttpServletRequest httpServletRequest, TokenProvider tokenProvider) {
+    public static JwtWithSource extractValidJwt(HttpServletRequest httpServletRequest, TokenProvider tokenProvider) {
         var cookie = WebUtils.getCookie(httpServletRequest, JWT_COOKIE_NAME);
         var authHeader = httpServletRequest.getHeader(AUTHORIZATION_HEADER);
 
@@ -83,7 +188,7 @@ public static String extractValidJwt(HttpServletRequest httpServletRequest, Toke
             return null;
         }
 
-        return jwtToken;
+        return new JwtWithSource(jwtToken, source);
     }
 
     /**
@@ -119,9 +224,9 @@ private static String getJwtFromBearer(@Nullable String jwtBearer) {
     /**
      * Checks if the jwt is valid
      *
-     * @param tokenProvider the Artemis token provider used to generate and validate jwt's
-     * @param jwtToken      the jwt token which should be validated
-     * @param source        the source of the jwt token
+     * @param tokenProvider used to generate and validate jwt's
+     * @param jwtToken      which should be validated
+     * @param source        of the jwt token
      * @return true if the jwt is valid, false if missing or invalid
      */
     private static boolean isJwtValid(TokenProvider tokenProvider, @Nullable String jwtToken, @Nullable String source) {
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java
new file mode 100644
index 000000000000..08f5a183e752
--- /dev/null
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java
@@ -0,0 +1,6 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import jakarta.validation.constraints.NotNull;
+
+public record JwtWithSource(@NotNull String jwt, @NotNull String source) {
+}
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
index d95a3e9de431..0ca6c3b6e502 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
@@ -10,6 +10,7 @@
 
 import jakarta.annotation.Nullable;
 import jakarta.annotation.PostConstruct;
+import jakarta.validation.constraints.NotNull;
 
 import javax.crypto.SecretKey;
 
@@ -28,6 +29,7 @@
 import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType;
 import io.jsonwebtoken.Claims;
 import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtBuilder;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.MalformedJwtException;
 import io.jsonwebtoken.UnsupportedJwtException;
@@ -44,6 +46,10 @@ public class TokenProvider {
 
     private static final String AUTHORITIES_KEY = "auth";
 
+    private static final String AUTHENTICATION_METHOD = "auth-method";
+
+    private static final String TOOLS_KEY = "tools";
+
     private SecretKey key;
 
     private long tokenValidityInMilliseconds;
@@ -83,7 +89,7 @@ public void init() {
      * Gets the validity for the generated tokens.
      *
      * @param rememberMe Determines Token lifetime
-     * @return long The validity of the generated tokens
+     * @return long The validity of the generated tokens in milliseconds
      */
     public long getTokenValidity(boolean rememberMe) {
         return rememberMe ? this.tokenValidityInMillisecondsForRememberMe : this.tokenValidityInMilliseconds;
@@ -96,6 +102,7 @@ public long getTokenValidity(boolean rememberMe) {
      * @param rememberMe     Determines Token lifetime
      * @return JWT Token
      */
+    @NotNull
     public String createToken(Authentication authentication, boolean rememberMe) {
         return createToken(authentication, getTokenValidity(rememberMe), null);
     }
@@ -104,20 +111,48 @@ public String createToken(Authentication authentication, boolean rememberMe) {
      * Create JWT Token a fully populated <code>Authentication</code> object.
      *
      * @param authentication Authentication Object
-     * @param duration       the Token lifetime in milli seconds
+     * @param duration       the Token lifetime in milliseconds
      * @param tool           tool this token is used for. If null, it's a general access token
      * @return JWT Token
      */
+    @NotNull
     public String createToken(Authentication authentication, long duration, @Nullable ToolTokenType tool) {
+        long validity = System.currentTimeMillis() + duration;
+        return createToken(authentication, null, new Date(validity), tool, null);
+    }
+
+    /**
+     * Create JWT Token a fully populated {@link Authentication} object.
+     *
+     * @param authentication           Authentication Object
+     * @param issuedAt                 Date when the token was issued, if null set to now
+     * @param expiration               Date when the token expires
+     * @param tool                     tool this token is used for. If null, it's a general access token
+     * @param authenticatedWithPasskey can be manually set to true if the token was created with a passkey but for performance reasons, no actual WebAuthnAuthentication was created
+     * @return JWT Token
+     */
+    @NotNull
+    public String createToken(Authentication authentication, @Nullable Date issuedAt, Date expiration, @Nullable ToolTokenType tool, @Nullable Boolean authenticatedWithPasskey) {
         String authorities = authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.joining(","));
 
-        var validity = System.currentTimeMillis() + duration;
-        var jwtBuilder = Jwts.builder().subject(authentication.getName()).claim(AUTHORITIES_KEY, authorities);
+        AuthenticationMethod authenticationMethod = AuthenticationMethod.fromAuthentication(authentication);
+        if (authenticatedWithPasskey != null && authenticatedWithPasskey) {
+            authenticationMethod = AuthenticationMethod.PASSKEY;
+        }
+
+        // @formatter:off
+        JwtBuilder jwtBuilder = Jwts.builder()
+            .subject(authentication.getName())
+            .claim(AUTHORITIES_KEY, authorities)
+            .claim(AUTHENTICATION_METHOD, authenticationMethod)
+            .issuedAt(issuedAt != null ? issuedAt : new Date());
+        // @formatter:on
+
         if (tool != null) {
-            jwtBuilder.claim("tools", tool);
+            jwtBuilder.claim(TOOLS_KEY, tool);
         }
 
-        return jwtBuilder.signWith(key, Jwts.SIG.HS512).expiration(new Date(validity)).compact();
+        return jwtBuilder.signWith(key, Jwts.SIG.HS512).expiration(expiration).compact();
     }
 
     /**
@@ -126,6 +161,7 @@ public String createToken(Authentication authentication, long duration, @Nullabl
      * @param token JWT Authorization Token
      * @return UsernamePasswordAuthenticationToken with principal, token and authorities that were stored in the token or null if no authorities were found
      */
+    @Nullable
     public Authentication getAuthentication(String token) {
         Claims claims = parseClaims(token);
         var authorityClaim = claims.get(AUTHORITIES_KEY);
@@ -185,16 +221,56 @@ private boolean validateJwsToken(String authToken, @Nullable String source) {
         return false;
     }
 
+    @NotNull
     private Claims parseClaims(String authToken) {
         return Jwts.parser().verifyWith(key).build().parseSignedClaims(authToken).getPayload();
     }
 
+    @NotNull
     public <T> T getClaim(String token, String claimName, Class<T> claimType) {
         Claims claims = parseClaims(token);
         return claims.get(claimName, claimType);
     }
 
+    @NotNull
     public Date getExpirationDate(String authToken) {
         return parseClaims(authToken).getExpiration();
     }
+
+    @NotNull
+    public Date getIssuedAtDate(String authToken) {
+        return parseClaims(authToken).getIssuedAt();
+    }
+
+    /**
+     * @param authToken of which the tools should be extracted
+     * @return {@link ToolTokenType} if the token contains a tool, null otherwise
+     */
+    @Nullable
+    public ToolTokenType getTools(String authToken) {
+        Claims claims = parseClaims(authToken);
+        String toolString = claims.get(TOOLS_KEY, String.class);
+
+        if (toolString == null) {
+            return null;
+        }
+
+        return ToolTokenType.valueOf(toolString);
+    }
+
+    /**
+     * @param authToken of which the authentication type on login should be extracted
+     * @return {@link AuthenticationMethod} that was used to create the token
+     */
+    @Nullable
+    public AuthenticationMethod getAuthenticationMethod(String authToken) {
+        try {
+            String method = parseClaims(authToken).get(AUTHENTICATION_METHOD, String.class);
+            return method != null ? AuthenticationMethod.fromMethod(method) : null;
+        }
+        catch (UnsupportedJwtException | IllegalArgumentException e) {
+            log.warn("Failed to parse authentication method from token: {}", e.getMessage());
+            return null;
+        }
+    }
 }
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java
index 8f5236e476bd..dbbec087a9cd 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java
@@ -26,7 +26,6 @@
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.multipart.MultipartFile;
 
-import de.tum.cit.aet.artemis.communication.service.notifications.SingleUserNotificationService;
 import de.tum.cit.aet.artemis.core.FilePathType;
 import de.tum.cit.aet.artemis.core.domain.User;
 import de.tum.cit.aet.artemis.core.dto.PasswordChangeDTO;
@@ -68,18 +67,15 @@ public class AccountResource {
 
     private final FileService fileService;
 
-    private final SingleUserNotificationService singleUserNotificationService;
-
     private static final float MAX_PROFILE_PICTURE_FILESIZE_IN_MEGABYTES = 0.1f;
 
-    public AccountResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, AccountService accountService, FileService fileService,
-            SingleUserNotificationService singleUserNotificationService) {
+    public AccountResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, AccountService accountService,
+            FileService fileService) {
         this.userRepository = userRepository;
         this.userService = userService;
         this.userCreationService = userCreationService;
         this.accountService = accountService;
         this.fileService = fileService;
-        this.singleUserNotificationService = singleUserNotificationService;
     }
 
     /**
diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java
index d40369dd81f2..b7057d0abf19 100644
--- a/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java
+++ b/src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java
@@ -21,6 +21,7 @@
 import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent;
 import de.tum.cit.aet.artemis.core.security.jwt.JWTCookieService;
 import de.tum.cit.aet.artemis.core.security.jwt.JWTFilter;
+import de.tum.cit.aet.artemis.core.security.jwt.JwtWithSource;
 import de.tum.cit.aet.artemis.core.security.jwt.TokenProvider;
 
 @Profile(PROFILE_CORE)
@@ -50,14 +51,14 @@ public TokenResource(JWTCookieService jwtCookieService, TokenProvider tokenProvi
     @EnforceAtLeastStudent
     public ResponseEntity<String> convertCookieToToolToken(@RequestParam(name = "tool", required = true) ToolTokenType tool,
             @RequestParam(name = "as-cookie", defaultValue = "false") boolean asCookie, HttpServletRequest request, HttpServletResponse response) {
-        // remaining time in milliseconds
-        var jwtToken = JWTFilter.extractValidJwt(request, tokenProvider);
-        if (jwtToken == null) {
+        JwtWithSource jwtWithSource = JWTFilter.extractValidJwt(request, tokenProvider);
+
+        if (jwtWithSource == null) {
             return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
         }
 
         // get validity of the token
-        long tokenRemainingTime = tokenProvider.getExpirationDate(jwtToken).getTime() - System.currentTimeMillis();
+        long tokenRemainingTime = tokenProvider.getExpirationDate(jwtWithSource.jwt()).getTime() - System.currentTimeMillis();
 
         // 1 day validity
         long maxDuration = Duration.ofDays(1).toMillis();
diff --git a/src/main/resources/config/application-core.yml b/src/main/resources/config/application-core.yml
index a2fbe397e5b6..0717b2080f50 100644
--- a/src/main/resources/config/application-core.yml
+++ b/src/main/resources/config/application-core.yml
@@ -13,6 +13,7 @@ artemis:
         passkey:
             enabled: false # used by Constants.PASSKEY_ENABLED_PROPERTY_NAME
             ask-users-to-setup: true # if true, users who have not set up a passkey will be asked to do so
+            token-validity-in-seconds-for-passkey: 15552000 # jwtToken is valid for 180 days when using passkey authentication
 
 spring:
     liquibase:
diff --git a/src/main/resources/config/application-prod.yml b/src/main/resources/config/application-prod.yml
index dc051c15a9b9..e46b82d92487 100644
--- a/src/main/resources/config/application-prod.yml
+++ b/src/main/resources/config/application-prod.yml
@@ -97,8 +97,8 @@ jhipster:
                 # - In the `JHIPSTER_SECURITY_AUTHENTICATION_JWT_BASE64SECRET` environment variable
                 # base64-secret: ""
                 # Token is valid 24 hours
-                token-validity-in-seconds: 86400
-                token-validity-in-seconds-for-remember-me: 2592000
+                token-validity-in-seconds: 86400 # Token is valid 24 hours
+                token-validity-in-seconds-for-remember-me: 2592000 # Token is valid 30 days (e.g. when using username/password authentication)
     mail: # specific JHipster mail property, for standard properties see MailProperties
         from: artemis@localhost
         base-url: http://my-server-url-to-change # Modify according to your server's URL
diff --git a/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java b/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java
new file mode 100644
index 000000000000..68105a6e123a
--- /dev/null
+++ b/src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java
@@ -0,0 +1,55 @@
+package de.tum.cit.aet.artemis.core.authentication;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.AuthenticatedPrincipal;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+
+import de.tum.cit.aet.artemis.core.security.Role;
+
+/**
+ * Factory for creating {@link org.springframework.security.core.Authentication} objects.
+ */
+public class AuthenticationFactory {
+
+    private AuthenticationFactory() {
+        // Prevent instantiation of this utility class
+    }
+
+    public static WebAuthnAuthentication createWebAuthnAuthentication(String username) {
+        Collection<GrantedAuthority> authorities = new ArrayList<>();
+        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
+
+        PublicKeyCredentialUserEntity principal = mock(PublicKeyCredentialUserEntity.class);
+        when(principal.getId()).thenReturn(new Bytes(username.getBytes(StandardCharsets.UTF_8)));
+        when(principal.getName()).thenReturn(username);
+        when(principal.getDisplayName()).thenReturn(username);
+
+        return new WebAuthnAuthentication(principal, authorities);
+    }
+
+    public static UsernamePasswordAuthenticationToken createUsernamePasswordAuthentication(String username) {
+        Collection<GrantedAuthority> authorities = new ArrayList<>();
+        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
+        return new UsernamePasswordAuthenticationToken(username, username, authorities);
+    }
+
+    public static Saml2Authentication createSaml2Authentication(String username) {
+        AuthenticatedPrincipal principal = () -> username;
+
+        Collection<GrantedAuthority> authorities = new ArrayList<>();
+        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
+        return new Saml2Authentication(principal, username, authorities);
+    }
+}
diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
new file mode 100644
index 000000000000..b7c1ca08a779
--- /dev/null
+++ b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
@@ -0,0 +1,330 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.net.URI;
+import java.util.Date;
+
+import jakarta.servlet.http.Cookie;
+
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+
+import de.tum.cit.aet.artemis.core.authentication.AuthenticationFactory;
+import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType;
+import de.tum.cit.aet.artemis.core.util.CookieParserTestUtil;
+import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest;
+
+/**
+ * <p>
+ * <strong>Integration test</strong> for the {@link JWTFilter}.
+ * </p>
+ */
+class JWTFilterIntegrationTest extends AbstractSpringIntegrationIndependentTest {
+
+    private static final String TEST_PREFIX = "jwtfilterintegrationtest";
+
+    private static final String USER_NAME = TEST_PREFIX + "student1";
+
+    /**
+     * This can be any endpoint where the JWT filter is applied.
+     */
+    private static final String ENDPOINT_TO_TEST = "/api/core/public/account";
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds}") // 86400 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS;
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}") // 2592000 when tests where written
+    private long TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS;
+
+    @Value("${artemis.user-management.passkey.token-validity-in-seconds-for-passkey}") // 1555200 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY;
+
+    @Autowired
+    private MockMvc mvc;
+
+    @Autowired
+    private TokenProvider tokenProvider;
+
+    @BeforeEach
+    void setup() {
+        userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0);
+    }
+
+    /**
+     * <p>
+     * <em>Note:</em> We assume that the following conditions hold:
+     * </p>
+     * <ul>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS}</li>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY}</li>
+     * </ul>
+     * <p>
+     * If this does not hold, some calculations might break.
+     * </p>
+     */
+    @Nested
+    class TokenRotationTests {
+
+        @Test
+        void testConfigurationContainsReasonableValues() {
+            assertThat(TOKEN_VALIDITY_IN_SECONDS).isGreaterThan(60);
+            assertThat(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS).isGreaterThan(TOKEN_VALIDITY_IN_SECONDS);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(0);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+        }
+
+        @Nested
+        class ShouldRotateTests {
+
+            /**
+             * <p>
+             * We want to rotate a passkey-created token silently if it has used after 50% of its lifetime
+             * </p>
+             * Ensures that the token is rotated properly without changing authentication data or the issuedAt date
+             */
+            @Test
+            void testMoreThanHalfOfLifetimeUsed() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future, but not too far in the future
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (0.9 * TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000));
+            }
+
+            /**
+             * We shall never extend the token lifetime beyond the maximum passkey token lifetime
+             */
+            @Test
+            void testConsidersMaxPasskeyTokenLifetime() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long nowInMilliseconds = System.currentTimeMillis();
+                Date issuedAt = new Date(nowInMilliseconds - (long) (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 0.9 * 1000));
+                Date expiration = new Date(nowInMilliseconds + (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000));
+
+                long tokenLifetimeAlreadyUsedUpInMilliseconds = nowInMilliseconds - issuedAt.getTime();
+                long expectedRemainingLifetimeInMilliseconds = TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 - tokenLifetimeAlreadyUsedUpInMilliseconds;
+                // if this is not the case, the test does not make sense - might happen if TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY or TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS is
+                // adjusted
+                // -> adjust issuedAt and expiration accordingly in that case (no full-time for TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS left, last rotated token before passkey token
+                // lifetime
+                // is reached)
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, expectedRemainingLifetimeInMilliseconds / 1000);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY);
+
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future but must not exceed the maximum passkey token lifetime
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (expectedRemainingLifetimeInMilliseconds * 0.9)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + expectedRemainingLifetimeInMilliseconds));
+            }
+
+        }
+
+        @Nested
+        class ShouldNotRotateTests {
+
+            @Test
+            void testExpiredToken() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long remainingValidityTimeOfTokenInMilliseconds = 1000;
+                Date issuedAt = new Date(System.currentTimeMillis() - (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000) + remainingValidityTimeOfTokenInMilliseconds);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 + remainingValidityTimeOfTokenInMilliseconds);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                Thread.sleep(remainingValidityTimeOfTokenInMilliseconds + 1000);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+            @Test
+            void testSuppliedByBearerToken() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                MockHttpServletResponse response = performRequest(jwt, true);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+            /**
+             * Ensure that the check cannot be bypassed by also passing the bearer token as Cookie; we do not want to rotate bearer tokens
+             */
+            @Test
+            void testSuppliedByBearerTokenAndCookie() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                LinkedMultiValueMap<String, String> params = new LinkedMultiValueMap<>();
+                HttpHeaders headers = new HttpHeaders();
+                headers.add(HttpHeaders.AUTHORIZATION, "Bearer " + jwt);
+
+                MvcResult res = mvc
+                        .perform(MockMvcRequestBuilders.get(new URI(ENDPOINT_TO_TEST)).params(params).headers(headers).cookie(new Cookie(JWTFilter.JWT_COOKIE_NAME, jwt)))
+                        .andExpect(status().is(HttpStatus.BAD_REQUEST.value())).andReturn();
+
+                MockHttpServletResponse response = res.getResponse();
+                assertThat(response).isNotNull();
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+            /**
+             * We DO NOT want to rotate a passkey-created token silently if it has used LESS THAN 50% of its lifetime
+             */
+            @Test
+            void testLessThanHalfOfLifetimeUsed() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long lessThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - lessThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+            @Test
+            void testTokenWasNotCreatedFromPasskey() throws Exception {
+                Authentication authentication = AuthenticationFactory.createUsernamePasswordAuthentication(USER_NAME);
+
+                long lessThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - lessThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, null);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSWORD);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+            /**
+             * Except for setting a {@link ToolTokenType} this test should be similar to a test that rotates the token (e.g.
+             * {@link ShouldRotateTests#testMoreThanHalfOfLifetimeUsed}
+             */
+            @Test
+            void testToolTokenCannotBeRefreshed() throws Exception {
+                Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, ToolTokenType.SCORPIO, true);
+                assertThat(tokenProvider.getAuthenticationMethod(jwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNull();
+            }
+
+        }
+    }
+
+    private MockHttpServletResponse performRequest(String jwt, boolean useBearerToken) throws Exception {
+        MockHttpServletRequestBuilder requestBuilder = MockMvcRequestBuilders.get(new URI(ENDPOINT_TO_TEST));
+        if (useBearerToken) {
+            requestBuilder.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt);
+        }
+        else {
+            requestBuilder.cookie(new Cookie(JWTFilter.JWT_COOKIE_NAME, jwt));
+        }
+        MvcResult res = mvc.perform(requestBuilder).andReturn();
+        MockHttpServletResponse response = res.getResponse();
+        assertThat(response).isNotNull();
+        return response;
+    }
+
+    /**
+     * Except for the maxAge and expiration (the expiration is not checked within this helper method), the updated cookie should be identical to the original one
+     */
+    private void validateUpdatedCookie(ResponseCookie updatedCookie, String originalJwt, Authentication expectedAuthentication, Date expectedIssuedAt,
+            long expectedMaxAgeInSeconds) {
+        assertThat(updatedCookie.getName()).isEqualTo(JWTFilter.JWT_COOKIE_NAME);
+        assertThat(updatedCookie.getPath()).isEqualTo("/");
+        assertThat(updatedCookie.getMaxAge().getSeconds()).isCloseTo(expectedMaxAgeInSeconds, Offset.offset(15L)); // prevents that the test is flaky on slow runners
+        assertThat(updatedCookie.isSecure()).isTrue();
+        assertThat(updatedCookie.isHttpOnly()).isTrue();
+        assertThat(updatedCookie.getSameSite()).isEqualTo("Lax");
+
+        String updatedJwt = updatedCookie.getValue();
+        assertThat(updatedJwt).isNotEmpty();
+        assertThat(updatedJwt).isNotEqualTo(originalJwt);
+        assertThat(tokenProvider.getAuthentication(updatedJwt).getPrincipal()).isEqualTo(expectedAuthentication.getPrincipal());
+        assertThat(tokenProvider.getAuthentication(updatedJwt).getAuthorities()).isEqualTo(expectedAuthentication.getAuthorities());
+        assertThat(tokenProvider.getAuthenticationMethod(updatedJwt)).isEqualTo(AuthenticationMethod.PASSKEY);
+        assertThat(tokenProvider.getIssuedAtDate(updatedJwt)).isCloseTo(expectedIssuedAt, 1000); // should not have changed, tolerance due to formatting
+    }
+
+}
diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java
index 4821084fb087..4fd702a7d269 100644
--- a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java
@@ -1,6 +1,7 @@
 package de.tum.cit.aet.artemis.core.security.jwt;
 
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
 
 import java.util.Collections;
 
@@ -26,6 +27,8 @@
 
 class JWTFilterTest {
 
+    private static final long TOKEN_VALIDITY_IN_MILLISECONDS = 60000; // 60 seconds
+
     private TokenProvider tokenProvider;
 
     private JWTFilter jwtFilter;
@@ -40,9 +43,11 @@ void setup() {
 
         tokenProvider = new TokenProvider(jHipsterProperties, securityMetersService);
         ReflectionTestUtils.setField(tokenProvider, "key", Keys.hmacShaKeyFor(Decoders.BASE64.decode(base64Secret)));
+        ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", TOKEN_VALIDITY_IN_MILLISECONDS);
+
+        JWTCookieService jwtCookieService = mock(JWTCookieService.class);
 
-        ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", 60000);
-        jwtFilter = new JWTFilter(tokenProvider);
+        jwtFilter = new JWTFilter(tokenProvider, jwtCookieService, 15552000);
         SecurityContextHolder.getContext().setAuthentication(null);
     }
 
diff --git a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
index 320121b09fa9..997f1cfc4bea 100644
--- a/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
+++ b/src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
@@ -4,22 +4,19 @@
 
 import java.nio.charset.StandardCharsets;
 import java.security.Key;
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Date;
 
 import javax.crypto.SecretKey;
 
 import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Test;
-import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.Authentication;
-import org.springframework.security.core.GrantedAuthority;
-import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.test.util.ReflectionTestUtils;
 
+import de.tum.cit.aet.artemis.core.authentication.AuthenticationFactory;
 import de.tum.cit.aet.artemis.core.management.SecurityMetersService;
-import de.tum.cit.aet.artemis.core.security.Role;
+import de.tum.cit.aet.artemis.core.security.allowedTools.ToolTokenType;
 import io.jsonwebtoken.Jwts;
 import io.jsonwebtoken.io.Decoders;
 import io.jsonwebtoken.security.Keys;
@@ -32,6 +29,8 @@ class TokenProviderTest {
 
     private static final long TEN_MINUTES = 600000;
 
+    private static final String USER_NAME = "anonymous";
+
     private SecretKey key;
 
     private TokenProvider tokenProvider;
@@ -50,7 +49,6 @@ void setup() {
         ReflectionTestUtils.setField(tokenProvider, "key", key);
         ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", ONE_MINUTE);
         ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMillisecondsForRememberMe", TEN_MINUTES);
-
     }
 
     @Test
@@ -62,7 +60,7 @@ void testReturnFalseWhenJWThasInvalidSignature() {
 
     @Test
     void testReturnFalseWhenJWTisMalformed() {
-        Authentication authentication = createAuthentication();
+        Authentication authentication = AuthenticationFactory.createUsernamePasswordAuthentication(USER_NAME);
         String token = tokenProvider.createToken(authentication, false);
         String invalidToken = token.substring(1);
         boolean isTokenValid = tokenProvider.validateTokenForAuthority(invalidToken, null);
@@ -74,7 +72,7 @@ void testReturnFalseWhenJWTisMalformed() {
     void testReturnFalseWhenJWTisExpired() {
         ReflectionTestUtils.setField(tokenProvider, "tokenValidityInMilliseconds", -ONE_MINUTE);
 
-        Authentication authentication = createAuthentication();
+        Authentication authentication = AuthenticationFactory.createUsernamePasswordAuthentication(USER_NAME);
         String token = tokenProvider.createToken(authentication, false);
 
         boolean isTokenValid = tokenProvider.validateTokenForAuthority(token, null);
@@ -142,10 +140,98 @@ void testGetTokenValidityNotRememberMe() {
         assertThat(validity).isEqualTo(ONE_MINUTE);
     }
 
-    private Authentication createAuthentication() {
-        Collection<GrantedAuthority> authorities = new ArrayList<>();
-        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
-        return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities);
+    @Test
+    void testGetIssuedAtDate() {
+        Date issuedAt = new Date();
+        String token = Jwts.builder().issuedAt(issuedAt).signWith(key, Jwts.SIG.HS512).compact();
+
+        Date result = tokenProvider.getIssuedAtDate(token);
+
+        assertThat(result).isNotNull().isCloseTo(issuedAt, 1000);
+    }
+
+    @Nested
+    class GetToolsTests {
+
+        @Test
+        void shouldBeRetrievedSuccessfully() {
+            ToolTokenType expectedTool = ToolTokenType.SCORPIO;
+            String token = Jwts.builder().claim("tools", expectedTool.toString()).signWith(key, Jwts.SIG.HS512).compact();
+
+            ToolTokenType actualTool = tokenProvider.getTools(token);
+
+            assertThat(actualTool).isNotNull().isEqualTo(expectedTool);
+        }
+
+        @Test
+        void shouldNotFailIfNull() {
+            String token = Jwts.builder().claim("someDummyClaim", true).signWith(key, Jwts.SIG.HS512).compact();
+
+            ToolTokenType actualTool = tokenProvider.getTools(token);
+
+            assertThat(actualTool).isNull();
+        }
+
+    }
+
+    @Nested
+    class GetAuthenticationMethodTests {
+
+        @Test
+        void shouldBeRetrievedSuccessfully() {
+            AuthenticationMethod expectedMethod = AuthenticationMethod.PASSKEY;
+            String token = Jwts.builder().claim("auth-method", expectedMethod.toString()).signWith(key, Jwts.SIG.HS512).compact();
+
+            AuthenticationMethod actualMethod = tokenProvider.getAuthenticationMethod(token);
+
+            assertThat(actualMethod).isNotNull().isEqualTo(expectedMethod);
+        }
+
+        @Test
+        void shouldNotFailIfNull() {
+            AuthenticationMethod expectedMethod = null;
+            String token = Jwts.builder().claim("auth-method", null).signWith(key, Jwts.SIG.HS512).compact();
+
+            AuthenticationMethod actualMethod = tokenProvider.getAuthenticationMethod(token);
+
+            assertThat(actualMethod).isNull();
+        }
+
+    }
+
+    @Nested
+    class AuthenticationMethodTests {
+
+        @Test
+        void shouldSetPasswordMethod() {
+            Authentication authentication = AuthenticationFactory.createUsernamePasswordAuthentication(USER_NAME);
+            String token = tokenProvider.createToken(authentication, false);
+
+            AuthenticationMethod authenticationMethod = tokenProvider.getAuthenticationMethod(token);
+
+            assertThat(authenticationMethod).isEqualTo(AuthenticationMethod.PASSWORD);
+        }
+
+        @Test
+        void shouldSetPasskeyMethod() {
+            Authentication authentication = AuthenticationFactory.createWebAuthnAuthentication(USER_NAME);
+            String token = tokenProvider.createToken(authentication, false);
+
+            AuthenticationMethod authenticationMethod = tokenProvider.getAuthenticationMethod(token);
+
+            assertThat(authenticationMethod).isEqualTo(AuthenticationMethod.PASSKEY);
+        }
+
+        @Test
+        void shouldSetSaml2Method() {
+            Authentication authentication = AuthenticationFactory.createSaml2Authentication(USER_NAME);
+            String token = tokenProvider.createToken(authentication, false);
+
+            AuthenticationMethod authenticationMethod = tokenProvider.getAuthenticationMethod(token);
+
+            assertThat(authenticationMethod).isEqualTo(AuthenticationMethod.SAML2);
+        }
+
     }
 
     private String createUnsupportedToken() {
@@ -155,6 +241,6 @@ private String createUnsupportedToken() {
     private String createTokenWithDifferentSignature() {
         SecretKey otherKey = Keys.hmacShaKeyFor(Decoders.BASE64.decode("Xfd54a45s65fds737b9aafcb3412e07ed99b267f33413274720ddbb7f6c5e64e9f14075f2d7ed041592f0b7657baf8"));
 
-        return Jwts.builder().subject("anonymous").signWith(otherKey, Jwts.SIG.HS512).expiration(new Date(new Date().getTime() + ONE_MINUTE)).compact();
+        return Jwts.builder().subject(USER_NAME).signWith(otherKey, Jwts.SIG.HS512).expiration(new Date(new Date().getTime() + ONE_MINUTE)).compact();
     }
 }
diff --git a/src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java b/src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
new file mode 100644
index 000000000000..fdeb9e668373
--- /dev/null
+++ b/src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
@@ -0,0 +1,57 @@
+package de.tum.cit.aet.artemis.core.util;
+
+import java.time.Duration;
+
+import org.springframework.http.ResponseCookie;
+
+public class CookieParserTestUtil {
+
+    private CookieParserTestUtil() {
+        // Utility class, no instantiation
+    }
+
+    public static ResponseCookie parseSetCookieHeader(String setCookieHeader) {
+        String[] parts = setCookieHeader.split(";");
+        String[] nameValue = parts[0].trim().split("=", 2);
+        String name = nameValue[0];
+        String value = nameValue.length > 1 ? nameValue[1] : "";
+
+        ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value);
+
+        for (int i = 1; i < parts.length; i++) {
+            String[] attr = parts[i].trim().split("=", 2);
+            String key = attr[0].trim().toLowerCase();
+            String val = attr.length > 1 ? attr[1].trim() : "";
+
+            switch (key) {
+                case "path":
+                    builder.path(val);
+                    break;
+                case "max-age":
+                    builder.maxAge(Duration.ofSeconds(Long.parseLong(val)));
+                    break;
+                case "expires":
+                    // ResponseCookie does not directly support `Expires`, prefer Max-Age
+                    break;
+                case "domain":
+                    builder.domain(val);
+                    break;
+                case "secure":
+                    builder.secure(true);
+                    break;
+                case "httponly":
+                    builder.httpOnly(true);
+                    break;
+                case "samesite":
+                    builder.sameSite(val);
+                    break;
+                default:
+                    // Unknown attribute
+                    break;
+            }
+        }
+
+        return builder.build();
+    }
+
+}


Reviews:
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T08:04:46Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 4**

<details>
<summary>🧹 Nitpick comments (4)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java (1)</summary><blockquote>

`25-29`: **Constructor properly updated to support JWT rotation**

The constructor has been appropriately updated to accept and initialize the new dependencies required for the token rotation mechanism. The method documentation could be updated to reflect these new parameters.

Consider updating the constructor's JavaDoc to include descriptions for the new parameters:

```diff
/**
 * Constructs a JWTConfigurer with a specified token provider.
 *
 * @param tokenProvider the provider responsible for generating and validating JWT tokens.
+ * @param jwtCookieService the service responsible for building JWT cookies
+ * @param tokenValidityInSecondsForPasskey the validity duration in seconds for passkey tokens
 */
```

</blockquote></details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java (1)</summary><blockquote>

`48-51`: **Replace magic numbers with named constants to keep tests resilient to configuration changes**

`15552000` seconds (≈ 180 days) is currently hard-coded when instantiating `JWTFilter`.  
If the configuration property `token-validity-in-seconds-for-passkey` is changed, the test will silently diverge from production behaviour.

```diff
-        JWTCookieService jwtCookieService = mock(JWTCookieService.class);
-        jwtFilter = new JWTFilter(tokenProvider, jwtCookieService, 15552000);
+        JWTCookieService jwtCookieService = mock(JWTCookieService.class);
+        long passkeyTokenValidity =
+            jHipsterProperties.getSecurity().getAuthentication().getJwt().getTokenValidityInSecondsForPasskey();
+        jwtFilter = new JWTFilter(tokenProvider, jwtCookieService, passkeyTokenValidity);
```

Using the configured value (or at least a `static final` constant named accordingly) avoids fragile tests.

</blockquote></details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java (1)</summary><blockquote>

`165-172`: **Prefer the public API constant for the signature algorithm**

`Jwts.SIG.HS512` works only with jjwt 0.12+. Using the stable `SignatureAlgorithm.HS512` (or the enum exposed by your BOM) avoids version-specific code and improves readability:

```diff
-import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
 …
-        String token = Jwts.builder().issuedAt(issuedAt).signWith(key, Jwts.SIG.HS512).compact();
+        String token = Jwts.builder().issuedAt(issuedAt).signWith(key, SignatureAlgorithm.HS512).compact();
```

If the project is already pinned to jjwt 0.12 this is optional; otherwise it prevents future upgrade friction.

</blockquote></details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java (1)</summary><blockquote>

`78-83`: **Fix Javadoc to describe actual validation**  
The Javadoc still refers to “server URL configuration is invalid”, but the method now validates the passkey JWT validity parameter.  
A quick wording update will avoid confusion for future maintainers.  

```diff
- * @throws IllegalStateException if the server URL configuration is invalid
+ * @throws IllegalStateException if the passkey token validity configuration is invalid
```

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between a40357af698a1c9000d4020155aa308a077d4532 and a4ed799009ae85049579a138ed4196f447782c53.

</details>

<details>
<summary>⛔ Files ignored due to path filters (2)</summary>

* `src/main/resources/config/application-core.yml` is excluded by `!**/*.yml`
* `src/main/resources/config/application-prod.yml` is excluded by `!**/*.yml`

</details>

<details>
<summary>📒 Files selected for processing (15)</summary>

* `src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java` (4 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java` (2 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java` (2 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java` (2 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java` (7 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java` (5 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java` (2 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationTestService.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java` (3 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java` (6 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java` (1 hunks)

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>📓 Path-based instructions (2)</summary>

<details>
<summary>`src/main/java/**/*.java`: naming:CamelCase; principles:{single_responsibility,small_methods,no_duplication}; db:{perf_queries,datetime_not_timestamp}; rest:{stateless,singleton,de...</summary>

> `src/main/java/**/*.java`: naming:CamelCase; principles:{single_responsibility,small_methods,no_duplication}; db:{perf_queries,datetime_not_timestamp}; rest:{stateless,singleton,delegate_logic,http_only,minimal_dtos}; dtos:{java_records,no_entities,min_data,single_resp}; di:constructor_injection; kiss:simple_code; file_handling:os_indep_paths; practices:{least_access,avoid_transactions,code_reuse,static_member_ref,prefer_primitives}; sql:{param_annotation,uppercase,avoid_subqueries};java:avoid_star_imports

- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java`
- `src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java`
- `src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java`
- `src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java`
- `src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java`

</details>
<details>
<summary>`src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_...</summary>

> `src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_use: enforce_package_rules; db_query_count_tests: track_performance; util_service_factory_pattern: true; avoid_db_access: true; mock_strategy: static_mocks; context_restart_minimize: true

- `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java`
- `src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java`
- `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java`
- `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java`
- `src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationTestService.java`

</details>

</details><details>
<summary>🧬 Code Graph Analysis (3)</summary>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java (1)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java (1)</summary>

* `JWTFilter` (27-205)

</details>

</blockquote></details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java (1)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java (1)</summary>

* `JWTFilter` (27-205)

</details>

</blockquote></details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java (1)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/Constants.java (1)</summary>

* `Constants` (8-499)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (6)</summary>

* GitHub Check: validate-pr-title
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: client-tests
* GitHub Check: server-tests
* GitHub Check: Analyse

</details>

<details>
<summary>🔇 Additional comments (16)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java (1)</summary>

`72-73`: **Constructor simplification looks good.**

The constructor signature has been updated to remove the `SingleUserNotificationService` parameter, which streamlines dependencies for this resource class. The change aligns with the broader JWT handling refactoring in the PR.

</details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationTestService.java (1)</summary>

`1-48`: **Well-structured test service for authentication mocks.**

The new `AuthenticationTestService` properly encapsulates authentication object creation for both WebAuthn and username/password authentication methods. The service is correctly limited to the test profile and follows good practices:

- Uses proper mocking to create test credentials
- Provides a consistent way to create authentication objects for tests
- Adheres to the single responsibility principle

This addition will support testing of the passkey token rotation mechanism described in the PR objectives.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JwtWithSource.java (1)</summary>

`1-7`: **Good use of Java record for immutable JWT container.**

The `JwtWithSource` record is a clean implementation that:
- Uses Java records appropriately for an immutable data carrier
- Properly enforces non-null constraints with `@NotNull` annotations
- Follows the single responsibility principle by focusing only on storing JWT data with its source

This record helps implement the token rotation mechanism by tracking both the JWT token and its source (cookie or bearer) throughout the application flow.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/websocket/WebsocketConfiguration.java (2)</summary>

`64-64`: **Appropriate import for new JWT handling type.**

The import for `JwtWithSource` is needed to support the updated JWT extraction mechanism.

---

`217-220`: **WebSocket handshake interceptor properly adapted for new JWT handling.**

The code has been refactored to use the new `JwtWithSource` type returned by `JWTFilter.extractValidJwt`. This change is necessary to support the broader JWT enhancements in the PR, including token rotation for passkey authentication.

The modification correctly:
- Stores the extracted JWT and its source information
- Maintains the same validation logic (checking for non-null return)
- Preserves the error handling for invalid authentication methods

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/allowedTools/ToolsInterceptor.java (2)</summary>

`17-17`: **Appropriate import addition for JwtWithSource**

The import for the new `JwtWithSource` record is correctly added, aligning with the updated JWT extraction mechanism.

---

`32-37`: **Good adaptation to the new JWT extraction mechanism**

The code has been properly updated to handle the new `JwtWithSource` return type from `JWTFilter.extractValidJwt()`. By explicitly initializing `jwtToken` to null and extracting the JWT string from the `JwtWithSource` object when present, the code maintains the original functionality while adapting to the new API.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java (1)</summary>

`67-75`: **Well-documented method for JWT rotation**

The new `buildRotatedCookie` method is well-designed and properly documented. It reuses the existing private `buildJWTCookie` method, maintaining consistency in cookie creation. This method supports the silent token rotation mechanism described in the PR objectives.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/web/TokenResource.java (3)</summary>

`24-24`: **Appropriate import addition for JwtWithSource**

The import for the new `JwtWithSource` record is correctly added to support the updated JWT extraction mechanism.

---

`54-56`: **Good adaptation to the new JWT extraction mechanism**

The code correctly uses the updated `JWTFilter.extractValidJwt()` method which now returns a `JwtWithSource` object instead of a direct JWT string. The null check has been appropriately shifted to check the `jwtWithSource` object.

---

`61-61`: **Proper JWT access from JwtWithSource**

The code correctly accesses the JWT string from the `JwtWithSource` object using the `jwt()` method when retrieving the expiration date from the token provider.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTConfigurer.java (2)</summary>

`16-18`: **Well-defined fields for JWT rotation support**

The addition of the `jwtCookieService` and `tokenValidityInSecondsForPasskey` fields properly supports the new token rotation mechanism. Making these fields `final` ensures immutability, which is a good practice for security-related components.

---

`40-40`: **Properly configured JWTFilter with new dependencies**

The `JWTFilter` instantiation has been correctly updated to pass the new dependencies required for implementing the token rotation mechanism.

</details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterTest.java (1)</summary>

`48-49`: **Stub the mock to prevent NullPointerExceptions when token rotation is exercised**

`JWTCookieService` is a bare Mockito mock. If the filter eventually calls `buildRotatedCookie(…)`, the mock will return `null`, potentially leading to an NPE inside the filter when it tries to add the cookie to the response.

Consider:

```java
when(jwtCookieService.buildRotatedCookie(anyString()))
    .thenReturn(ResponseCookie.from(JWTFilter.JWT_COOKIE_NAME, "dummy").build());
```

Or use `lenient()` if the call is not always exercised.

</details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java (1)</summary>

`48-56`: **Configuration property name differs from the one advertised in the PR – check for mismatch**

The test injects  
`artemis.user-management.passkey.token-validity-in-seconds-for-passkey`,  
whereas the PR description and docs reference  
`jhipster.security.authentication.jwt.token-validity-in-seconds-for-passkey`.

If only one of them is present in `application.yml`, the other will resolve to `0`, breaking assertions such as line 90 / 91.

Please verify the actual property key and align both code and documentation.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/config/SecurityConfiguration.java (1)</summary>

`69-74`: **Verify property key naming and default**  
`artemis.user-management.passkey.token-validity-in-seconds-for-passkey` contains “passkey” twice and differs from the conventional `token-validity-in-seconds` wording used elsewhere in the codebase.  
Please check that  
• the key matches the value configured in `application.yml` / Helm charts, and  
• there is no typo that would silently fall back to the default.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:30:16Z
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:39:39Z
user: login: coderabbitai[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:40:15Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:43:50Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:45:48Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T09:52:50Z
user: login: PaRangger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:52:43Z
user: login: PaRangger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:54:11Z
user: login: PaRangger
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:56:13Z
user: login: PaRangger
body: Left some optional nitpicks, but overall the code LGTM 😄 
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T10:56:33Z
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:47:24Z
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:51:15Z
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:52:09Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:54:41Z
user: login: tobias-lippert
body: see my inline comments and server-style fails
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T14:36:25Z
user: login: florian-glombik
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T19:40:21Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>🧹 Nitpick comments (2)</summary><blockquote>

<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java (2)</summary><blockquote>

`28-32`: **Consider adding test for null username parameter.**

While the current implementation works for the happy path, it would be more robust to add validation for null username parameters.

```diff
public static WebAuthnAuthentication createWebAuthnAuthentication(String username) {
+    if (username == null) {
+        throw new IllegalArgumentException("Username cannot be null");
+    }
    Collection<GrantedAuthority> authorities = new ArrayList<>();
    authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));

    PublicKeyCredentialUserEntity principal = mock(PublicKeyCredentialUserEntity.class);
    when(principal.getId()).thenReturn(new Bytes(username.getBytes(StandardCharsets.UTF_8)));
    when(principal.getName()).thenReturn(username);
    when(principal.getDisplayName()).thenReturn(username);

    return new WebAuthnAuthentication(principal, authorities);
}
```

---

`1-43`: **Consider adding methods for other authentication scenarios.**

Since this class will likely be used extensively in testing the new rolling token mechanism, consider expanding it with additional factory methods for other test scenarios, such as:

1. Authentication with specific roles beyond ANONYMOUS
2. Authentication with expired tokens
3. Authentication with tokens approaching renewal threshold

These would make tests for the token rotation feature more readable and maintainable.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**
**Cache: Disabled due to data retention organization setting**
**Knowledge Base: Disabled due to data retention organization setting**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 78e5c4174cb3aa5ace6bcff7fc9c4a1af1f13a4c and d5fdb29e98358470971975a83946e3c91526e3db.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java` (5 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java` (6 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java` (1 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (5)</summary>

* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java
* src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
* src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
* src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>📓 Path-based instructions (1)</summary>

<details>
<summary>`src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_...</summary>

> `src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_use: enforce_package_rules; db_query_count_tests: track_performance; util_service_factory_pattern: true; avoid_db_access: true; mock_strategy: static_mocks; context_restart_minimize: true

- `src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java`

</details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (8)</summary>

* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build .war artifact
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: client-tests
* GitHub Check: client-style
* GitHub Check: server-style
* GitHub Check: server-tests
* GitHub Check: Analyse

</details>

<details>
<summary>🔇 Additional comments (3)</summary><blockquote>

<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java (3)</summary>

`19-22`: **Well-structured test utility class following factory pattern.**

The class follows the factory pattern correctly, providing a clear interface for creating authentication objects. This design properly isolates test setup code and promotes reuse across test cases.

---

`24-34`: **Good implementation of WebAuthn authentication factory method.**

The `createWebAuthnAuthentication` method correctly mocks a `PublicKeyCredentialUserEntity` and properly configures it with the supplied username. The consistent use of static mocks aligns with the project's mock strategy guidelines.

---

`36-40`: **Consistent authentication creation pattern.**

The method for creating username/password authentication follows the same pattern as the WebAuthn method, which promotes consistency and maintainability.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T19:50:00Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T19:50:03Z
user: login: PaRangger
body: Reapprove Code after new commits ✅ 
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T20:01:48Z
user: login: tobias-lippert
body: code
Thanks for incorporating my change requests.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T21:37:48Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T08:34:29Z
user: login: PaRangger
body: Reapprove code.
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T09:53:27Z
user: login: tobias-lippert
body: Code
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T10:35:52Z
user: login: HawKhiem
body: Tested on TS3. Works as expected
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T16:39:22Z
user: login: coderabbitai[bot]
body: **Actionable comments posted: 0**

<details>
<summary>♻️ Duplicate comments (1)</summary><blockquote>

<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java (1)</summary><blockquote>

`153-235`: **Tests could follow 'shouldXWhenY' naming convention.**

Consider renaming test methods to follow the "shouldXYWhenYZ" pattern as mentioned in previous review comments.



For example:
```diff
- void testGetIssuedAtDate() {
+ void shouldReturnCorrectIssuedAtDateWhenTokenContainsIt() {
```

</blockquote></details>

</blockquote></details>

<details>
<summary>🧹 Nitpick comments (5)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java (2)</summary><blockquote>

`22-29`: **Consider adding defensive null handling for fromAuthentication.**

The method returns null for unrecognized authentication types, which could lead to NullPointerExceptions if callers don't handle this case.


```diff
public static AuthenticationMethod fromAuthentication(Authentication authentication) {
    return switch (authentication) {
        case WebAuthnAuthentication webAuthnAuthentication -> AuthenticationMethod.PASSKEY;
        case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> AuthenticationMethod.PASSWORD;
        case Saml2Authentication saml2Authentication -> AuthenticationMethod.SAML2;
-       case null, default -> null;
+       case null, default -> AuthenticationMethod.PASSWORD; // Or throw IllegalArgumentException for consistency with fromMethod
    };
}
```

---

`31-38`: **Inconsistent behavior between fromAuthentication and fromMethod.**

`fromAuthentication` returns null for unknown authentication types, while `fromMethod` throws an exception. Consider aligning these behaviors for consistency.

</blockquote></details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java (3)</summary><blockquote>

`128-129`: **Use safer string comparison to avoid NPE.**

The condition checks `source.equals("cookie")` which could cause a NullPointerException if `source` is null.

```diff
-    boolean isConsideredASecureToken = source.equals("cookie") && this.tokenProvider.getTools(jwtToken) == null;
+    boolean isConsideredASecureToken = "cookie".equals(source) && this.tokenProvider.getTools(jwtToken) == null;
```

---

`78-105`: **Consider breaking down the token rotation logic.**

The `rotateTokenSilently` method is complex and could benefit from extracting some of the logic into helper methods.

```diff
private void rotateTokenSilently(String jwtToken, Authentication authentication, HttpServletResponse response) throws NotAuthorizedException {
    boolean isARefreshableToken = this.tokenProvider.getAuthenticationMethod(jwtToken).equals(AuthenticationMethod.PASSKEY);
    if (!isARefreshableToken) {
        return;
    }

    Date issuedAt = this.tokenProvider.getIssuedAtDate(jwtToken);
    Date expirationDate = this.tokenProvider.getExpirationDate(jwtToken);

    if (shouldRotateToken(issuedAt, expirationDate)) {
        String rotatedToken = createRotatedToken(jwtToken, authentication, issuedAt);
        setRotatedTokenCookie(rotatedToken, response);
    }
}

private boolean shouldRotateToken(Date issuedAt, Date expirationDate) {
    long currentTime = System.currentTimeMillis();
    long tokenValidityInMilliseconds = this.tokenProvider.getTokenValidity(true);
    long remainingLifetime = expirationDate.getTime() - currentTime;
    
    return remainingLifetime < tokenValidityInMilliseconds / 2;
}

private String createRotatedToken(String jwtToken, Authentication authentication, Date issuedAt) {
    long nowInMilliseconds = System.currentTimeMillis();
    long tokenValidityInMilliseconds = this.tokenProvider.getTokenValidity(true);
    long newTokenExpirationTimeInMilliseconds = Math.min(nowInMilliseconds + tokenValidityInMilliseconds,
            issuedAt.getTime() + Math.multiplyExact(this.tokenValidityInSecondsForPasskey, 1000));
    
    return this.tokenProvider.createToken(authentication, issuedAt, new Date(newTokenExpirationTimeInMilliseconds), 
            this.tokenProvider.getTools(jwtToken), true);
}

private void setRotatedTokenCookie(String rotatedToken, HttpServletResponse response) {
    long nowInMilliseconds = System.currentTimeMillis();
    long tokenValidityInMilliseconds = this.tokenProvider.getTokenValidity(true);
    long rotatedTokenDurationInMilliseconds = calculateTokenDuration(rotatedToken, nowInMilliseconds, tokenValidityInMilliseconds);
    
    ResponseCookie responseCookie = jwtCookieService.buildRotatedCookie(rotatedToken, rotatedTokenDurationInMilliseconds);
    response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
}

private long calculateTokenDuration(String token, long nowInMilliseconds, long tokenValidityInMilliseconds) {
    Date expirationDate = this.tokenProvider.getExpirationDate(token);
    return expirationDate.getTime() - nowInMilliseconds;
}
```

---

`84-97`: **Use a more descriptive variable name for timing calculations.**

The variable `nowInMilliseconds` is used before it's declared. Consider moving the declaration earlier and using a more descriptive name like `currentTimeInMilliseconds`.

</blockquote></details>

</blockquote></details>

<details>
<summary>📜 Review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 1fc1b6581a57306ec6ef102575aba6f0f1622159 and 0efa6a04e3cfc96bfc86323b00e6a89f73a8044a.

</details>

<details>
<summary>📒 Files selected for processing (6)</summary>

* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java` (7 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java` (5 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java` (1 hunks)
* `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java` (6 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (3)</summary>

* src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
* src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java
* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java

</details>

<details>
<summary>🧰 Additional context used</summary>

<details>
<summary>📓 Path-based instructions (2)</summary>

<details>
<summary>`src/main/java/**/*.java`: naming:CamelCase; principles:{single_responsibility,small_methods,no_duplication}; db:{perf_queries,datetime_not_timestamp}; rest:{stateless,singleton,de...</summary>

> `src/main/java/**/*.java`: naming:CamelCase; principles:{single_responsibility,small_methods,no_duplication}; db:{perf_queries,datetime_not_timestamp}; rest:{stateless,singleton,delegate_logic,http_only,minimal_dtos}; dtos:{java_records,no_entities,min_data,single_resp}; di:constructor_injection; kiss:simple_code; file_handling:os_indep_paths; practices:{least_access,avoid_transactions,code_reuse,static_member_ref,prefer_primitives}; sql:{param_annotation,uppercase,avoid_subqueries};java:avoid_star_imports

- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java`
- `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java`

</details>
<details>
<summary>`src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_...</summary>

> `src/test/java/**/*.java`: test_naming: descriptive; test_size: small_specific; fixed_data: true; junit5_features: true; assert_use: assertThat; assert_specificity: true; archunit_use: enforce_package_rules; db_query_count_tests: track_performance; util_service_factory_pattern: true; avoid_db_access: true; mock_strategy: static_mocks; context_restart_minimize: true

- `src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java`

</details>

</details><details>
<summary>🧬 Code Graph Analysis (1)</summary>

<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java (1)</summary><blockquote>

<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java (1)</summary>

* `AuthenticationFactory` (24-55)

</details>

</blockquote></details>

</details>

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (6)</summary>

* GitHub Check: server-tests
* GitHub Check: client-tests
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build .war artifact
* GitHub Check: Analyse

</details>

<details>
<summary>🔇 Additional comments (11)</summary><blockquote>

<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java (1)</summary>

`8-39`: **Clean implementation of the authentication method enum.**

The AuthenticationMethod enum is well-structured and provides a clear representation of the supported authentication methods. The pattern matching in the switch expression is an elegant approach for mapping authentication objects to their respective methods.

</details>
<details>
<summary>src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java (6)</summary>

`32-33`: **Good use of constants.**

Extracting the username as a constant improves readability and maintainability.

---

`63-64`: **Improved test authentication creation.**

Using the AuthenticationFactory for creating test authentication objects enhances code reuse and consistency.

---

`143-151`: **Good test for getIssuedAtDate.**

This test properly verifies the retrieval of the issued-at date from a token.

---

`153-175`: **Well-structured tests for tool token extraction.**

The GetToolsTests nested class provides good coverage for tool token extraction, including null-safe handling.

---

`177-201`: **Good authentication method extraction tests.**

The GetAuthenticationMethodTests nested class properly verifies extraction of authentication methods from tokens.

---

`202-235`: **Comprehensive authentication method tests.**

The AuthenticationMethodTests nested class provides good coverage for different authentication methods.

</details>
<details>
<summary>src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java (4)</summary>

`39-45`: **Constructor properly initializes all required dependencies.**

The constructor accepts and initializes all required dependencies for token rotation.

---

`47-105`: **Well-documented token rotation logic.**

The Javadoc for `rotateTokenSilently` is comprehensive and clearly explains the purpose, configuration, and security implications of token rotation.

---

`147-168`: **Good update to return both JWT and its source.**

The `extractValidJwt` method now returns a `JwtWithSource` record, which provides better encapsulation and more context to callers.

---

`107-137`: **Properly handles null authentication.**

The code now properly checks that authentication is not null before attempting to rotate the token, addressing the previous review comment about null safety.

</details>

</blockquote></details>

</details>

<!-- This is an auto-generated comment by CodeRabbit for review status -->
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T23:13:16Z
user: login: coderabbitai[bot]
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T23:13:19Z
user: login: tobias-lippert
body: Reapprove code
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:13:11Z
user: login: coderabbitai[bot]
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-22T20:15:04Z
user: login: krusche
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T20:18:08Z

Review comments:
diff_hunk: @@ -30,17 +34,83 @@ public class JWTFilter extends GenericFilterBean {
 
     private final TokenProvider tokenProvider;
 
-    public JWTFilter(TokenProvider tokenProvider) {
+    private final JWTCookieService jwtCookieService;
+
+    private final long tokenValidityInSecondsForPasskey;
+
+    public JWTFilter(TokenProvider tokenProvider, JWTCookieService jwtCookieService, long tokenValidityInSecondsForPasskey) {
         this.tokenProvider = tokenProvider;
+        this.jwtCookieService = jwtCookieService;
+        this.tokenValidityInSecondsForPasskey = tokenValidityInSecondsForPasskey;
+    }
+
+    /**
+     * Renews the JWT token if its remaining lifetime is less than 50% of its total validity period.
+     * The renewed token retains the original issuedAt timestamp.
+     *
+     * <p>
+     * <b>Configurable values:</b>
+     * </p>
+     * <ul>
+     * <li>{@code artemis.user-management.passkey.token-validity-in-seconds-for-passkey}:
+     * Caps the maximum lifetime of a renewed token, limiting how long an infrequently used
+     * token can remain valid.</li>
+     * <li>{@code jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}:
+     * Determines the expiration time for authentication tokens.</li>
+     * </ul>
+     *
+     * <p>
+     * <b>Security:</b>
+     * </p>
+     * <p>
+     * This mechanism relies on secure cookie storage and HTTPS. Note that no token revocation
+     * mechanism is implemented. Role changes are not checked, as resources for non-admin roles
+     * will validate the current role during access.
+     * </p>
+     *
+     * @param jwtToken       The current JWT token to evaluate for renewal.
+     * @param authentication The {@link org.springframework.security.core.Authentication} object
+     *                           associated with the token.
+     * @param response       The {@link jakarta.servlet.http.HttpServletResponse} where the renewed
+     *                           token will be added as a cookie.
+     * @throws NotAuthorizedException If the token cannot be renewed due to validation or other issues.
+     */
+    private void rotateTokenSilently(String jwtToken, Authentication authentication, HttpServletResponse response) throws NotAuthorizedException {
+        Date issuedAt = this.tokenProvider.getIssuedAtDate(jwtToken);
+        Date expirationDate = this.tokenProvider.getExpirationDate(jwtToken);
+
+        long currentTime = System.currentTimeMillis();
+        long tokenValidityInMilliseconds = this.tokenProvider.getTokenValidity(true);
+        long remainingLifetime = expirationDate.getTime() - currentTime;
+
+        boolean isRemainingLifetimeBelowHalf = remainingLifetime < tokenValidityInMilliseconds / 2;
+        if (isRemainingLifetimeBelowHalf) {
+            long nowInMilliseconds = System.currentTimeMillis();
+            long newTokenExpirationTimeInMilliseconds = Math.min(nowInMilliseconds + tokenValidityInMilliseconds,
+                    issuedAt.getTime() + Math.multiplyExact(this.tokenValidityInSecondsForPasskey, 1000));
+            long rotatedTokenDurationInMilliseconds = newTokenExpirationTimeInMilliseconds - nowInMilliseconds;
+
+            String rotatedToken = this.tokenProvider.createToken(authentication, issuedAt, new Date(newTokenExpirationTimeInMilliseconds), this.tokenProvider.getTools(jwtToken),
+                    true);
+
+            ResponseCookie responseCookie = jwtCookieService.buildRotatedCookie(rotatedToken, rotatedTokenDurationInMilliseconds);
+
+            response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
+        }
     }
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
user: login: florian-glombik
body: This is the core (and security relevant) change
created_at: 2025-05-20T08:04:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,51 @@
+package de.tum.cit.aet.artemis.core.util;
+
+import org.springframework.http.ResponseCookie;
+
+public class CookieParserTestUtil {
+
+    public static ResponseCookie parseSetCookieHeader(String setCookieHeader) {
+        String[] parts = setCookieHeader.split(";");
+        String[] nameValue = parts[0].trim().split("=", 2);
+        String name = nameValue[0];
+        String value = nameValue.length > 1 ? nameValue[1] : "";
+
+        ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value);
+
+        for (int i = 1; i < parts.length; i++) {
+            String[] attr = parts[i].trim().split("=", 2);
+            String key = attr[0].trim().toLowerCase();
+            String val = attr.length > 1 ? attr[1].trim() : "";
+
+            switch (key) {
+                case "path":
+                    builder.path(val);
+                    break;
+                case "max-age":
+                    builder.maxAge(Long.parseLong(val));
+                    break;
+                case "expires":
path: src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**`max-age` parsing breaks compilation with Spring 6 – convert seconds to `Duration`**

`ResponseCookie.ResponseCookieBuilder#maxAge` expects a `Duration`; there is no overload accepting a `long` in Spring 6+.  
Compilation will therefore fail.

```diff
-import java.time.Duration;
 …
-                case "max-age":
-                    builder.maxAge(Long.parseLong(val));
+                case "max-age":
+                    // value is expressed in seconds according to RFC 6265
+                    builder.maxAge(Duration.ofSeconds(Long.parseLong(val)));
```

Also consider surrounding `Long.parseLong(val)` with a try/catch to ignore malformed values instead of throwing `NumberFormatException` in a utility used by many tests.  



> Committable suggestion skipped: line range outside the PR's diff.

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
around lines 24 to 27, the call to builder.maxAge(Long.parseLong(val)) breaks
compilation with Spring 6 because maxAge now expects a Duration instead of a
long. Fix this by parsing the long value and converting it to a Duration using
Duration.ofSeconds. Additionally, wrap the parsing in a try/catch block to
handle NumberFormatException gracefully and ignore malformed values without
throwing exceptions, as this utility is used by many tests.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T09:30:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,311 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.net.URI;
+import java.util.Date;
+
+import jakarta.servlet.http.Cookie;
+
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+
+import de.tum.cit.aet.artemis.core.authentication.AuthenticationTestService;
+import de.tum.cit.aet.artemis.core.util.CookieParserTestUtil;
+import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest;
+
+/**
+ * <p>
+ * <strong>Integration test</strong> for the {@link JWTFilter}.
+ * </p>
+ */
+class JWTFilterIntegrationTest extends AbstractSpringIntegrationIndependentTest {
+
+    private static final String TEST_PREFIX = "jwtfilterintegrationtest";
+
+    private static final String USER_NAME = TEST_PREFIX + "student1";
+
+    /**
+     * This can be any endpoint where the JWT filter is applied.
+     */
+    private static final String ENDPOINT_TO_TEST = "/api/core/public/account";
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds}") // 86400 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS;
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}") // 2592000 when tests where written
+    private long TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS;
+
+    @Value("${artemis.user-management.passkey.token-validity-in-seconds-for-passkey}") // 1555200 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY;
+
+    @Autowired
+    private MockMvc mvc;
+
+    @Autowired
+    private TokenProvider tokenProvider;
+
+    @Autowired
+    private AuthenticationTestService authenticationTestService;
+
+    @BeforeEach
+    void setup() {
+        userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0);
+    }
+
+    /**
+     * <p>
+     * <em>Note:</em> We assume that the following conditions hold:
+     * </p>
+     * <ul>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS}</li>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY}</li>
+     * </ul>
+     * <p>
+     * If this does not hold, some calculations might break.
+     * </p>
+     */
+    @Nested
+    class TokenRotationTests {
+
+        @Test
+        void testConfigurationContainsReasonableValues() {
+            assertThat(TOKEN_VALIDITY_IN_SECONDS).isGreaterThan(60);
+            assertThat(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS).isGreaterThan(TOKEN_VALIDITY_IN_SECONDS);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(0);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+        }
+
+        @Nested
+        class ShouldRotateTests {
+
+            /**
+             * <p>
+             * We want to rotate a passkey-created token silently if it has used after 50% of its lifetime
+             * </p>
+             * Ensures that the token is rotated properly without changing authentication data or the issuedAt date
+             */
+            @Test
+            void testMoreThanHalfOfLifetimeUsed() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future, but not too far in the future
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (0.9 * TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000));
+            }
+
+            /**
+             * We shall never extend the token lifetime beyond the maximum passkey token lifetime
+             */
+            @Test
+            void testConsidersMaxPasskeyTokenLifetime() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long nowInMilliseconds = System.currentTimeMillis();
+                Date issuedAt = new Date(nowInMilliseconds - (long) (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 0.9 * 1000));
+                Date expiration = new Date(nowInMilliseconds + (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000));
+
+                long tokenLifetimeAlreadyUsedUpInMilliseconds = nowInMilliseconds - issuedAt.getTime();
+                long expectedRemainingLifetimeInMilliseconds = TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 - tokenLifetimeAlreadyUsedUpInMilliseconds;
+                // if this is not the case, the test does not make sense - might happen if TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY or TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS is
+                // adjusted
+                // -> adjust issuedAt and expiration accordingly in that case (no full-time for TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS left, last rotated token before passkey token
+                // lifetime
+                // is reached)
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, expectedRemainingLifetimeInMilliseconds / 1000);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY);
+
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future but must not exceed the maximum passkey token lifetime
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (expectedRemainingLifetimeInMilliseconds * 0.9)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + expectedRemainingLifetimeInMilliseconds));
+            }
+
+        }
+
+        @Nested
+        class ShouldNotRotateTests {
+
+            @Test
+            void testExpiredToken() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long remainingValidityTimeOfTokenInMilliseconds = 1000;
+                Date issuedAt = new Date(System.currentTimeMillis() - (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000) + remainingValidityTimeOfTokenInMilliseconds);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 + remainingValidityTimeOfTokenInMilliseconds);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                Thread.sleep(remainingValidityTimeOfTokenInMilliseconds + 1000);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

**Avoid `Thread.sleep` in tests to reduce flakiness and execution time**

The test waits ≈ 2 s to let a token expire. On slower CI runners this can still be flaky and it lengthens the suite.

Instead, create a token that is already expired:

```diff
-                long remainingValidityTimeOfTokenInMilliseconds = 1000;
-                Date issuedAt = new Date(System.currentTimeMillis()
-                        - (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000) + remainingValidityTimeOfTokenInMilliseconds);
-                Date expiration = new Date(issuedAt.getTime()
-                        + TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 + remainingValidityTimeOfTokenInMilliseconds);
-                …
-                Thread.sleep(remainingValidityTimeOfTokenInMilliseconds + 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
+                Date expiration = new Date(System.currentTimeMillis() - 100); // already expired
```

This removes the sleep altogether while preserving the intent of testing expired-token behaviour.  



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);

                Date issuedAt = new Date(System.currentTimeMillis() - TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
                Date expiration = new Date(System.currentTimeMillis() - 100); // already expired

                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();

                MockHttpServletResponse response = performRequest(jwt, false);

                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In
src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
around lines 174 to 187, the test uses Thread.sleep to wait for the token to
expire, which causes flakiness and slows down the test suite. Instead of
sleeping, modify the token creation to set the issuedAt and expiration dates so
that the token is already expired at creation time. This removes the need for
Thread.sleep while still testing the expired-token behavior effectively.
```

</details>

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-20T09:30:14Z
author_association: CONTRIBUTOR
diff_hunk: @@ -49,6 +119,11 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo
 
         if (jwtToken != null) {
             Authentication authentication = this.tokenProvider.getAuthentication(jwtToken);
+
+            if (source.equals("cookie") && this.tokenProvider.getAuthenticatedWithPasskey(jwtToken)) {
+                rotateTokenSilently(jwtToken, authentication, httpServletResponse);
+            }
+
             SecurityContextHolder.getContext().setAuthentication(authentication);
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
user: login: coderabbitai[bot]
body: _🛠️ Refactor suggestion_

_⚠️ Potential issue_

**Null-safety: guard against tokens without authorities**  
`TokenProvider#getAuthentication` can return `null` (e.g. tokens without the `auth` claim).  
Calling `rotateTokenSilently` or installing `null` into the `SecurityContext` will raise an NPE.


```diff
-        Authentication authentication = this.tokenProvider.getAuthentication(jwtToken);

-        if (source.equals("cookie") && this.tokenProvider.getAuthenticatedWithPasskey(jwtToken)) {
-            rotateTokenSilently(jwtToken, authentication, httpServletResponse);
-        }
-
-        SecurityContextHolder.getContext().setAuthentication(authentication);
+        Authentication authentication = this.tokenProvider.getAuthentication(jwtToken);
+
+        if (authentication != null) {
+            if ("cookie".equals(source) && this.tokenProvider.getAuthenticatedWithPasskey(jwtToken)) {
+                rotateTokenSilently(jwtToken, authentication, httpServletResponse);
+            }
+            SecurityContextHolder.getContext().setAuthentication(authentication);
+        }
```



<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
            Authentication authentication = this.tokenProvider.getAuthentication(jwtToken);

            if (authentication != null) {
                if ("cookie".equals(source) && this.tokenProvider.getAuthenticatedWithPasskey(jwtToken)) {
                    rotateTokenSilently(jwtToken, authentication, httpServletResponse);
                }
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java around
lines 121 to 127, the method getAuthentication can return null for tokens
without authorities, causing a NullPointerException when calling
rotateTokenSilently or setting the authentication in SecurityContextHolder. Add
a null check after obtaining the authentication object; only call
rotateTokenSilently and set the authentication in the SecurityContext if
authentication is not null to ensure null-safety.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T09:30:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,4 +224,26 @@ public <T> T getClaim(String token, String claimName, Class<T> claimType) {
     public Date getExpirationDate(String authToken) {
         return parseClaims(authToken).getExpiration();
     }
+
+    public Date getIssuedAtDate(String authToken) {
+        return parseClaims(authToken).getIssuedAt();
+    }
+
+    public ToolTokenType getTools(String authToken) {
+        Claims claims = parseClaims(authToken);
+        String toolString = claims.get(TOOLS_KEY, String.class);
+
+        if (toolString == null) {
+            return null;
+        }
+
+        return ToolTokenType.valueOf(toolString);
+    }
+
+    /**
+     * True if the user authenticated with a passkey
+     */
+    public boolean getAuthenticatedWithPasskey(String authToken) {
+        return parseClaims(authToken).get(AUTHENTICATED_WITH_PASSKEY_KEY, Boolean.class);
+    }
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
user: login: coderabbitai[bot]
body: _⚠️ Potential issue_

**Defensive null handling for legacy tokens**  
`parseClaims(...).get(..., Boolean.class)` may return `null` for tokens created before this PR.  
Automatic unboxing to the primitive `boolean` will throw a `NullPointerException`, effectively logging out every user with an old token.


```diff
-    public boolean getAuthenticatedWithPasskey(String authToken) {
-        return parseClaims(authToken).get(AUTHENTICATED_WITH_PASSKEY_KEY, Boolean.class);
+    public boolean getAuthenticatedWithPasskey(String authToken) {
+        Boolean passkeyClaim = parseClaims(authToken).get(AUTHENTICATED_WITH_PASSKEY_KEY, Boolean.class);
+        return Boolean.TRUE.equals(passkeyClaim);
     }
```


<!-- suggestion_start -->

<details>
<summary>📝 Committable suggestion</summary>

> ‼️ **IMPORTANT**
> Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

```suggestion
    public boolean getAuthenticatedWithPasskey(String authToken) {
        Boolean passkeyClaim = parseClaims(authToken).get(AUTHENTICATED_WITH_PASSKEY_KEY, Boolean.class);
        return Boolean.TRUE.equals(passkeyClaim);
    }
```

</details>

<!-- suggestion_end -->

<details>
<summary>🤖 Prompt for AI Agents</summary>

```
In src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
around lines 246 to 248, the method getAuthenticatedWithPasskey returns a
Boolean that may be null for legacy tokens, causing a NullPointerException on
unboxing. Modify the method to defensively check if the returned Boolean is null
and return false in that case to avoid exceptions and ensure backward
compatibility.
```

</details>

<!-- This is an auto-generated comment by CodeRabbit -->
created_at: 2025-05-20T09:30:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,311 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.net.URI;
+import java.util.Date;
+
+import jakarta.servlet.http.Cookie;
+
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+
+import de.tum.cit.aet.artemis.core.authentication.AuthenticationTestService;
+import de.tum.cit.aet.artemis.core.util.CookieParserTestUtil;
+import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest;
+
+/**
+ * <p>
+ * <strong>Integration test</strong> for the {@link JWTFilter}.
+ * </p>
+ */
+class JWTFilterIntegrationTest extends AbstractSpringIntegrationIndependentTest {
+
+    private static final String TEST_PREFIX = "jwtfilterintegrationtest";
+
+    private static final String USER_NAME = TEST_PREFIX + "student1";
+
+    /**
+     * This can be any endpoint where the JWT filter is applied.
+     */
+    private static final String ENDPOINT_TO_TEST = "/api/core/public/account";
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds}") // 86400 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS;
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}") // 2592000 when tests where written
+    private long TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS;
+
+    @Value("${artemis.user-management.passkey.token-validity-in-seconds-for-passkey}") // 1555200 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY;
+
+    @Autowired
+    private MockMvc mvc;
+
+    @Autowired
+    private TokenProvider tokenProvider;
+
+    @Autowired
+    private AuthenticationTestService authenticationTestService;
+
+    @BeforeEach
+    void setup() {
+        userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0);
+    }
+
+    /**
+     * <p>
+     * <em>Note:</em> We assume that the following conditions hold:
+     * </p>
+     * <ul>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS}</li>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY}</li>
+     * </ul>
+     * <p>
+     * If this does not hold, some calculations might break.
+     * </p>
+     */
+    @Nested
+    class TokenRotationTests {
+
+        @Test
+        void testConfigurationContainsReasonableValues() {
+            assertThat(TOKEN_VALIDITY_IN_SECONDS).isGreaterThan(60);
+            assertThat(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS).isGreaterThan(TOKEN_VALIDITY_IN_SECONDS);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(0);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+        }
+
+        @Nested
+        class ShouldRotateTests {
+
+            /**
+             * <p>
+             * We want to rotate a passkey-created token silently if it has used after 50% of its lifetime
+             * </p>
+             * Ensures that the token is rotated properly without changing authentication data or the issuedAt date
+             */
+            @Test
+            void testMoreThanHalfOfLifetimeUsed() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future, but not too far in the future
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (0.9 * TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000));
+            }
+
+            /**
+             * We shall never extend the token lifetime beyond the maximum passkey token lifetime
+             */
+            @Test
+            void testConsidersMaxPasskeyTokenLifetime() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long nowInMilliseconds = System.currentTimeMillis();
+                Date issuedAt = new Date(nowInMilliseconds - (long) (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 0.9 * 1000));
+                Date expiration = new Date(nowInMilliseconds + (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000));
+
+                long tokenLifetimeAlreadyUsedUpInMilliseconds = nowInMilliseconds - issuedAt.getTime();
+                long expectedRemainingLifetimeInMilliseconds = TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 - tokenLifetimeAlreadyUsedUpInMilliseconds;
+                // if this is not the case, the test does not make sense - might happen if TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY or TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS is
+                // adjusted
+                // -> adjust issuedAt and expiration accordingly in that case (no full-time for TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS left, last rotated token before passkey token
+                // lifetime
+                // is reached)
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, expectedRemainingLifetimeInMilliseconds / 1000);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY);
+
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future but must not exceed the maximum passkey token lifetime
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (expectedRemainingLifetimeInMilliseconds * 0.9)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + expectedRemainingLifetimeInMilliseconds));
+            }
+
+        }
+
+        @Nested
+        class ShouldNotRotateTests {
+
+            @Test
+            void testExpiredToken() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long remainingValidityTimeOfTokenInMilliseconds = 1000;
+                Date issuedAt = new Date(System.currentTimeMillis() - (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000) + remainingValidityTimeOfTokenInMilliseconds);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 + remainingValidityTimeOfTokenInMilliseconds);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                Thread.sleep(remainingValidityTimeOfTokenInMilliseconds + 1000);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
user: login: florian-glombik
body: We need to wait here, we cannot create a token in the past and we don't want to reduce the security by design for token creation for testing purposes
created_at: 2025-05-20T09:39:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,311 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.net.URI;
+import java.util.Date;
+
+import jakarta.servlet.http.Cookie;
+
+import org.assertj.core.data.Offset;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
+import org.springframework.mock.web.MockHttpServletResponse;
+import org.springframework.security.core.Authentication;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.MvcResult;
+import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.util.LinkedMultiValueMap;
+
+import de.tum.cit.aet.artemis.core.authentication.AuthenticationTestService;
+import de.tum.cit.aet.artemis.core.util.CookieParserTestUtil;
+import de.tum.cit.aet.artemis.shared.base.AbstractSpringIntegrationIndependentTest;
+
+/**
+ * <p>
+ * <strong>Integration test</strong> for the {@link JWTFilter}.
+ * </p>
+ */
+class JWTFilterIntegrationTest extends AbstractSpringIntegrationIndependentTest {
+
+    private static final String TEST_PREFIX = "jwtfilterintegrationtest";
+
+    private static final String USER_NAME = TEST_PREFIX + "student1";
+
+    /**
+     * This can be any endpoint where the JWT filter is applied.
+     */
+    private static final String ENDPOINT_TO_TEST = "/api/core/public/account";
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds}") // 86400 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS;
+
+    @Value("${jhipster.security.authentication.jwt.token-validity-in-seconds-for-remember-me}") // 2592000 when tests where written
+    private long TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS;
+
+    @Value("${artemis.user-management.passkey.token-validity-in-seconds-for-passkey}") // 1555200 when tests where written
+    private long TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY;
+
+    @Autowired
+    private MockMvc mvc;
+
+    @Autowired
+    private TokenProvider tokenProvider;
+
+    @Autowired
+    private AuthenticationTestService authenticationTestService;
+
+    @BeforeEach
+    void setup() {
+        userUtilService.addUsers(TEST_PREFIX, 1, 0, 0, 0);
+    }
+
+    /**
+     * <p>
+     * <em>Note:</em> We assume that the following conditions hold:
+     * </p>
+     * <ul>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS}</li>
+     * <li>{@link JWTFilterIntegrationTest#TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS} &lt; {@link JWTFilterIntegrationTest#TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY}</li>
+     * </ul>
+     * <p>
+     * If this does not hold, some calculations might break.
+     * </p>
+     */
+    @Nested
+    class TokenRotationTests {
+
+        @Test
+        void testConfigurationContainsReasonableValues() {
+            assertThat(TOKEN_VALIDITY_IN_SECONDS).isGreaterThan(60);
+            assertThat(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS).isGreaterThan(TOKEN_VALIDITY_IN_SECONDS);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(0);
+            assertThat(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY).isGreaterThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+        }
+
+        @Nested
+        class ShouldRotateTests {
+
+            /**
+             * <p>
+             * We want to rotate a passkey-created token silently if it has used after 50% of its lifetime
+             * </p>
+             * Ensures that the token is rotated properly without changing authentication data or the issuedAt date
+             */
+            @Test
+            void testMoreThanHalfOfLifetimeUsed() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long moreThanHalfOfTokenValidityPassed = (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.6 * 1000);
+                Date issuedAt = new Date(System.currentTimeMillis() - moreThanHalfOfTokenValidityPassed);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future, but not too far in the future
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (0.9 * TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000));
+            }
+
+            /**
+             * We shall never extend the token lifetime beyond the maximum passkey token lifetime
+             */
+            @Test
+            void testConsidersMaxPasskeyTokenLifetime() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long nowInMilliseconds = System.currentTimeMillis();
+                Date issuedAt = new Date(nowInMilliseconds - (long) (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 0.9 * 1000));
+                Date expiration = new Date(nowInMilliseconds + (long) (TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 0.4 * 1000));
+
+                long tokenLifetimeAlreadyUsedUpInMilliseconds = nowInMilliseconds - issuedAt.getTime();
+                long expectedRemainingLifetimeInMilliseconds = TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 - tokenLifetimeAlreadyUsedUpInMilliseconds;
+                // if this is not the case, the test does not make sense - might happen if TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY or TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS is
+                // adjusted
+                // -> adjust issuedAt and expiration accordingly in that case (no full-time for TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS left, last rotated token before passkey token
+                // lifetime
+                // is reached)
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000);
+                assertThat(expectedRemainingLifetimeInMilliseconds).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS * 1000);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
+                assertThat(setCookieHeader).isNotNull();
+                ResponseCookie updatedCookie = CookieParserTestUtil.parseSetCookieHeader(setCookieHeader);
+
+                validateUpdatedCookie(updatedCookie, jwt, tokenProvider.getAuthentication(jwt), issuedAt, expectedRemainingLifetimeInMilliseconds / 1000);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_REMEMBER_ME_IN_SECONDS);
+                assertThat(updatedCookie.getMaxAge().getSeconds()).isLessThan(TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY);
+
+                String updatedJwt = updatedCookie.getValue();
+                // IMPORTANT! The expiration date of the rotated token must be in the future but must not exceed the maximum passkey token lifetime
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isAfter(new Date(System.currentTimeMillis() + (long) (expectedRemainingLifetimeInMilliseconds * 0.9)));
+                assertThat(tokenProvider.getExpirationDate(updatedJwt)).isBefore(new Date(System.currentTimeMillis() + expectedRemainingLifetimeInMilliseconds));
+            }
+
+        }
+
+        @Nested
+        class ShouldNotRotateTests {
+
+            @Test
+            void testExpiredToken() throws Exception {
+                Authentication authentication = authenticationTestService.createWebAuthnAuthentication(USER_NAME);
+
+                long remainingValidityTimeOfTokenInMilliseconds = 1000;
+                Date issuedAt = new Date(System.currentTimeMillis() - (TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000) + remainingValidityTimeOfTokenInMilliseconds);
+                Date expiration = new Date(issuedAt.getTime() + TOKEN_VALIDITY_IN_SECONDS_FOR_PASSKEY * 1000 + remainingValidityTimeOfTokenInMilliseconds);
+
+                String jwt = tokenProvider.createToken(authentication, issuedAt, expiration, null, true);
+                assertThat(tokenProvider.getAuthenticatedWithPasskey(jwt)).isTrue();
+
+                Thread.sleep(remainingValidityTimeOfTokenInMilliseconds + 1000);
+
+                MockHttpServletResponse response = performRequest(jwt, false);
+
+                String setCookieHeader = response.getHeader(HttpHeaders.SET_COOKIE);
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilterIntegrationTest.java
user: login: coderabbitai[bot]
body: @florian-glombik I understand your concern. You're right that maintaining the security design integrity even in test code is important, especially for authentication systems. Using natural token expiration through `Thread.sleep()` preserves the security properties of your token system, even if it means slightly longer test execution times. Thank you for explaining the reasoning behind this approach.


<!-- <agentComponentType>chat-answer</agentComponentType> -->

<!-- <rawResChunk><answer>@florian-glombik I understand your concern. You're right that maintaining the security design integrity even in test code is important, especially for authentication systems. Using natural token expiration through `Thread.sleep()` preserves the security properties of your token system, even if it means slightly longer test execution times. Thank you for explaining the reasoning behind this approach.</answer></rawResChunk> -->

<!-- This is an auto-generated reply by CodeRabbit -->
created_at: 2025-05-20T09:40:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,10 +141,54 @@ void testGetTokenValidityNotRememberMe() {
         assertThat(validity).isEqualTo(ONE_MINUTE);
     }
 
-    private Authentication createAuthentication() {
-        Collection<GrantedAuthority> authorities = new ArrayList<>();
-        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
-        return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities);
+    @Test
+    void testAuthenticatedWithPasskey_isFalseWhenAuthenticatedWithPassword() {
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
user: login: PaRangger
body: According to the [docs](https://docs.artemis.cit.tum.de/dev/guidelines/server-tests.html#general-testing-tips) we should call the test cases "shouldXYWhenYZ".
created_at: 2025-05-20T10:52:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,6 +64,16 @@ public ResponseCookie buildLoginCookie(long duration, ToolTokenType tool) {
         return buildJWTCookie(jwt, Duration.of(duration, ChronoUnit.MILLIS));
     }
 
+    /**
+     * Builds the cookie containing the jwt for a login
+     *
+     * @param rotatedJwtToken with the updated values
+     * @param duration        of the cookie in milliseconds and the jwt
+     */
+    public ResponseCookie buildRotatedCookie(String rotatedJwtToken, long duration) {
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTCookieService.java
user: login: PaRangger
body: We could call the duration parameter here something like "durationInMillis"?
created_at: 2025-05-20T10:54:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,4 +224,27 @@ public <T> T getClaim(String token, String claimName, Class<T> claimType) {
     public Date getExpirationDate(String authToken) {
         return parseClaims(authToken).getExpiration();
     }
+
+    public Date getIssuedAtDate(String authToken) {
+        return parseClaims(authToken).getIssuedAt();
+    }
+
+    public ToolTokenType getTools(String authToken) {
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
user: login: PaRangger
body: Not sure if necessary, but might profit from some javadoc comments here.
created_at: 2025-05-20T10:56:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,10 +141,54 @@ void testGetTokenValidityNotRememberMe() {
         assertThat(validity).isEqualTo(ONE_MINUTE);
     }
 
-    private Authentication createAuthentication() {
-        Collection<GrantedAuthority> authorities = new ArrayList<>();
-        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
-        return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities);
+    @Test
+    void testAuthenticatedWithPasskey_isFalseWhenAuthenticatedWithPassword() {
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
user: login: florian-glombik
body: Thanks for the headups and the link, I have missed that we have another page with testing guidelines 👍 
created_at: 2025-05-20T11:47:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -142,10 +141,54 @@ void testGetTokenValidityNotRememberMe() {
         assertThat(validity).isEqualTo(ONE_MINUTE);
     }
 
-    private Authentication createAuthentication() {
-        Collection<GrantedAuthority> authorities = new ArrayList<>();
-        authorities.add(new SimpleGrantedAuthority(Role.ANONYMOUS.getAuthority()));
-        return new UsernamePasswordAuthenticationToken("anonymous", "anonymous", authorities);
+    @Test
+    void testAuthenticatedWithPasskey_isFalseWhenAuthenticatedWithPassword() {
path: src/test/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProviderTest.java
user: login: florian-glombik
body: I also had another look at `JWTFilterIntegrationTest.java` but I would say that even though it does not 100% adhere to the naming convention the current naming is easier to understand than if we'd follow the convention - do you agree? 
created_at: 2025-05-20T11:51:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -197,4 +224,27 @@ public <T> T getClaim(String token, String claimName, Class<T> claimType) {
     public Date getExpirationDate(String authToken) {
         return parseClaims(authToken).getExpiration();
     }
+
+    public Date getIssuedAtDate(String authToken) {
+        return parseClaims(authToken).getIssuedAt();
+    }
+
+    public ToolTokenType getTools(String authToken) {
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java
user: login: florian-glombik
body: As it doesn't seem that you have a strong opinion here, I would argue that it only clutters the code and the code should be sufficient as documentation in this case 😅 
created_at: 2025-05-20T11:52:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,53 @@
+package de.tum.cit.aet.artemis.core.util;
+
+import java.time.Duration;
+
+import org.springframework.http.ResponseCookie;
+
+public class CookieParserTestUtil {
+
path: src/test/java/de/tum/cit/aet/artemis/core/util/CookieParserTestUtil.java
user: login: tobias-lippert
body: private constructor because it's a utility class.
created_at: 2025-05-20T14:35:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+package de.tum.cit.aet.artemis.core.authentication;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static tech.jhipster.config.JHipsterConstants.SPRING_PROFILE_TEST;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+import org.springframework.stereotype.Service;
+
+import de.tum.cit.aet.artemis.core.security.Role;
+
+/**
+ * This service helps to generate {@link org.springframework.security.core.Authentication} objects.
+ */
+@Service
+@Profile(SPRING_PROFILE_TEST)
+public class AuthenticationTestService {
path: src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationTestService.java
user: login: tobias-lippert
body: This can be a simple factory class. No need to make it a spring-managed component.
created_at: 2025-05-20T14:35:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+package de.tum.cit.aet.artemis.core.authentication;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static tech.jhipster.config.JHipsterConstants.SPRING_PROFILE_TEST;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collection;
+
+import org.springframework.context.annotation.Profile;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.web.webauthn.api.Bytes;
+import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+import org.springframework.stereotype.Service;
+
+import de.tum.cit.aet.artemis.core.security.Role;
+
+/**
+ * This service helps to generate {@link org.springframework.security.core.Authentication} objects.
+ */
+@Service
+@Profile(SPRING_PROFILE_TEST)
+public class AuthenticationTestService {
path: src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationTestService.java
user: login: florian-glombik
body: Should have spend more thoughts on that, thanks for the input 👍 
created_at: 2025-05-20T19:40:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2,31 +2,26 @@
 
 import static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
-import static tech.jhipster.config.JHipsterConstants.SPRING_PROFILE_TEST;
 
 import java.nio.charset.StandardCharsets;
 import java.util.ArrayList;
 import java.util.Collection;
 
-import org.springframework.context.annotation.Profile;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.GrantedAuthority;
 import org.springframework.security.core.authority.SimpleGrantedAuthority;
 import org.springframework.security.web.webauthn.api.Bytes;
 import org.springframework.security.web.webauthn.api.PublicKeyCredentialUserEntity;
 import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
-import org.springframework.stereotype.Service;
 
 import de.tum.cit.aet.artemis.core.security.Role;
 
 /**
- * This service helps to generate {@link org.springframework.security.core.Authentication} objects.
+ * Factory for creating {@link org.springframework.security.core.Authentication} objects.
  */
-@Service
-@Profile(SPRING_PROFILE_TEST)
-public class AuthenticationTestService {
+public class AuthenticationFactory {
path: src/test/java/de/tum/cit/aet/artemis/core/authentication/AuthenticationFactory.java
user: login: tobias-lippert
body: nitpick: util classes should have a private constructor
created_at: 2025-05-20T20:01:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,39 @@
+package de.tum.cit.aet.artemis.core.security.jwt;
+
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.saml2.provider.service.authentication.Saml2Authentication;
+import org.springframework.security.web.webauthn.authentication.WebAuthnAuthentication;
+
+public enum AuthenticationMethod {
+
+    PASSKEY("passkey"), PASSWORD("password"), SAML2("saml2");
+
+    private final String method;
+
+    AuthenticationMethod(String method) {
+        this.method = method;
+    }
+
+    public String getMethod() {
+        return method;
+    }
+
+    public static AuthenticationMethod fromAuthentication(Authentication authentication) {
+        return switch (authentication) {
+            case WebAuthnAuthentication webAuthnAuthentication -> AuthenticationMethod.PASSKEY;
+            case UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken -> AuthenticationMethod.PASSWORD;
+            case Saml2Authentication saml2Authentication -> AuthenticationMethod.SAML2;
+            case null, default -> null;
path: src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java
user: login: tobias-lippert
body: Add @NotNull and avoid the default case?
created_at: 2025-05-22T20:11:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-05-09T19:09:01Z
body: ### End-to-End (E2E) Test Results Summary

 
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-15T23:51:01Z
body: ### End-to-End (E2E) Test Results Summary

 
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-17T00:08:53Z
body: ### End-to-End (E2E) Test Results Summary

 
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-18T20:09:07Z
body: ### End-to-End (E2E) Test Results Summary

 
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-19T11:52:24Z
body: ### End-to-End (E2E) Test Results Summary

 
author_association: NONE
user: login: coderabbitai[bot]
created_at: 2025-05-20T09:30:11Z
body: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

## Walkthrough

The changes introduce support for passkey-based JWT authentication with silent token rotation in cookies. New claims are added to JWTs, and token extraction methods now return both the token and its source. The JWT filter and related classes are refactored to handle passkey authentication, token renewal, and cookie management. Associated configuration, tests, and utility classes are updated or introduced to support and verify these features.

## Changes

| File(s) | Change Summary |
|---------|---------------|
| `.../config/SecurityConfiguration.java`, `.../security/jwt/JWTConfigurer.java` | Security configuration updated to inject `JWTCookieService` and support passkey token validity. `JWTConfigurer` constructor extended to accept cookie service and passkey validity duration. |
| `.../security/jwt/JWTFilter.java`, `.../security/jwt/JwtWithSource.java` | `JWTFilter` refactored to support silent JWT rotation for passkey-authenticated users. Token extraction now returns a `JwtWithSource` (token + source). New private method for silent rotation. New `JwtWithSource` record introduced. |
| `.../security/jwt/TokenProvider.java`, `.../security/jwt/AuthenticationMethod.java` | JWTs now include `"auth-method"` and `"tools"` claims. Methods added to create tokens with explicit issued-at/expiration, and to extract claims including authentication method. New enum `AuthenticationMethod` added for claim values. |
| `.../security/jwt/JWTCookieService.java` | New public method `buildRotatedCookie` to create cookies from externally rotated JWTs. |
| `.../config/websocket/WebsocketConfiguration.java`, `.../security/allowedTools/ToolsInterceptor.java`, `.../web/TokenResource.java` | JWT extraction updated to use `JwtWithSource` instead of raw string tokens. Logic updated accordingly. |
| `.../web/AccountResource.java` | Removed dependency on `SingleUserNotificationService` from constructor and imports. |
| `.../authentication/AuthenticationFactory.java` | New test utility class for creating mock authentication objects for tests. |
| `.../util/CookieParserTestUtil.java` | New utility for parsing `Set-Cookie` headers into `ResponseCookie` objects for testing. |
| `.../security/jwt/JWTFilterIntegrationTest.java` | New integration tests for JWT filter, focusing on silent rotation for passkey tokens, including cookie and bearer scenarios. |
| `.../security/jwt/JWTFilterTest.java` | Test setup updated for new `JWTFilter` constructor and token validity constant. |
| `.../security/jwt/TokenProviderTest.java` | Tests refactored to use `AuthenticationFactory`, extended to cover passkey claims, issued-at extraction, and tool claim scenarios. |

## Sequence Diagram(s)

### Silent JWT Rotation for Passkey-Authenticated Users

```mermaid
sequenceDiagram
    participant Client
    participant JWTFilter
    participant TokenProvider
    participant JWTCookieService
    participant HttpServletResponse

    Client->>JWTFilter: Sends request with JWT cookie
    JWTFilter->>TokenProvider: Validate JWT and extract claims
    TokenProvider-->>JWTFilter: Returns authentication, issuedAt, expiration, passkey flag
    alt Token from cookie & passkey-authenticated & >50% lifetime used
        JWTFilter->>TokenProvider: Create new JWT (rotated) with same issuedAt
        TokenProvider-->>JWTFilter: Returns rotated JWT
        JWTFilter->>JWTCookieService: buildRotatedCookie(rotated JWT, duration)
        JWTCookieService-->>JWTFilter: Returns ResponseCookie
        JWTFilter->>HttpServletResponse: Set rotated JWT cookie
    end
    JWTFilter-->>Client: Continue filter chain (possibly with rotated JWT cookie)
```

### JWT Extraction with Source

```mermaid
sequenceDiagram
    participant RequestHandler
    participant JWTFilter
    participant TokenProvider

    RequestHandler->>JWTFilter: Extract valid JWT from request
    JWTFilter->>TokenProvider: Check for JWT in cookie and header
    TokenProvider-->>JWTFilter: Returns JWT and source (cookie/bearer)
    JWTFilter-->>RequestHandler: Returns JwtWithSource (jwt, source)
```

### JWT Creation with Passkey and Tool Claims

```mermaid
sequenceDiagram
    participant Application
    participant TokenProvider

    Application->>TokenProvider: createToken(authentication, issuedAt, expiration, tool, passkeyFlag)
    TokenProvider->>TokenProvider: Add claims: auth-method, tools
    TokenProvider-->>Application: Returns signed JWT
```

## Possibly related PRs

- ls1intum/Artemis#10656: Introduced passkey login feature that this PR extends with JWT cookie service integration and token validity configuration for passkey authentication.

## Suggested reviewers

- florian-glombik
- tobias-lippert

<!-- walkthrough_end -->

<!-- announcements_start -->

> [!NOTE]
> <details>
> <summary>⚡️ AI Code Reviews for VS Code, Cursor, Windsurf</summary>
> 
> CodeRabbit now has a plugin for VS Code, Cursor and Windsurf. This brings AI code reviews directly in the code editor. Each commit is reviewed immediately, finding bugs before the PR is raised. Seamless context handoff to your AI code agent ensures that you can easily incorporate review feedback.
> Learn more [here](http://coderabbit.ai/ide).
> 
> </details>

---

> [!NOTE]
> <details>
> <summary>⚡️ Faster reviews with caching</summary>
> 
> CodeRabbit now supports caching for code and dependencies, helping speed up reviews. This means quicker feedback, reduced wait times, and a smoother review experience overall. Cached data is encrypted and stored securely. This feature will be automatically enabled for all accounts on May 30th. To opt out, configure `Review - Disable Cache` at either the organization or repository level. If you prefer to disable all data retention across your organization, simply turn off the `Data Retention` setting under your Organization Settings.
> Enjoy the performance boost—your workflow just got faster.
> 
> </details>

<!-- announcements_end -->

---

<details>
<summary>📜 Recent review details</summary>

**Configuration used: .coderabbit.yaml**
**Review profile: CHILL**
**Plan: Pro**


<details>
<summary>📥 Commits</summary>

Reviewing files that changed from the base of the PR and between 0efa6a04e3cfc96bfc86323b00e6a89f73a8044a and 46cb0ebca1c5ad57a896f8d48c527ec6771d6204.

</details>

<details>
<summary>📒 Files selected for processing (3)</summary>

* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java` (1 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java` (6 hunks)
* `src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java` (8 hunks)

</details>

<details>
<summary>🚧 Files skipped from review as they are similar to previous changes (3)</summary>

* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/AuthenticationMethod.java
* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/JWTFilter.java
* src/main/java/de/tum/cit/aet/artemis/core/security/jwt/TokenProvider.java

</details>

<details>
<summary>⏰ Context from checks skipped due to timeout of 90000ms (8)</summary>

* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build and Push Docker Image / Build Docker Image for ls1intum/artemis
* GitHub Check: Build .war artifact
* GitHub Check: client-style
* GitHub Check: client-tests
* GitHub Check: server-tests
* GitHub Check: server-style
* GitHub Check: Analyse

</details>

</details>
<!-- internal state start -->


<!-- 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= -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details open="true">
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

<details>
<summary>❤️ Share</summary>

- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)
- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)
- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)
- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)

</details>

<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=ls1intum/Artemis&utm_content=10830):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Explain this complex logic.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai explain this code block.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and explain its main purpose.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

### Support

Need help? Create a ticket on our [support page](https://www.coderabbit.ai/contact-us/support) for assistance with any issues or questions.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai generate sequence diagram` to generate a sequence diagram of the changes in this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` or `@coderabbitai title` anywhere in the PR title to generate the title automatically.

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-20T09:33:44Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped</th><th>Failed</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>1 ran</td><td>1 passed</td><td>0 skipped</td><td>0 failed</td><td>1s 652ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3">No test annotations available</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T09:51:46Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped</th><th>Failed</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>1 ran</td><td>1 passed</td><td>0 skipped</td><td>0 failed</td><td>1s 770ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3">No test annotations available</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T10:32:58Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped ⚠️</th><th>Failed</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>198 passed</td><td>3 skipped</td><td>0 failed</td><td>54m 24s 338ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3">No test annotations available</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T12:35:33Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ☑️</th><th>Skipped ⚠️</th><th>Failed ❌️</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>197 passed</td><td>3 skipped</td><td>1 failed</td><td>56m 9s 78ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3"><strong>End-to-End (E2E) Test Report</strong></td></tr><tr><td colspan="3"><em>e2e/exercise/programming/ProgrammingExerciseStaticCodeAnalysis.spec.ts</em></td></tr><tr><td>ts.Static code analysis tests › Configures SCA grading and makes a successful submission with SCA errors</td><td>❌ failure</td><td>1m 50s 411ms</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-20T20:30:47Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ✅</th><th>Skipped ⚠️</th><th>Failed</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>198 passed</td><td>3 skipped</td><td>0 failed</td><td>54m 27s 41ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3">No test annotations available</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T09:16:34Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ☑️</th><th>Skipped ⚠️</th><th>Failed ❌️</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>197 passed</td><td>3 skipped</td><td>1 failed</td><td>58m 26s 388ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3"><strong>End-to-End (E2E) Test Report</strong></td></tr><tr><td colspan="3"><em>e2e/exercise/quiz-exercise/QuizExerciseParticipation.spec.ts</em></td></tr><tr><td>ts.Quiz Exercise Participation › DnD Quiz participation › Student can participate in DnD Quiz</td><td>❌ failure</td><td>2m 4s 46ms</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-21T23:50:38Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ☑️</th><th>Skipped ⚠️</th><th>Failed ❌️</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>197 passed</td><td>3 skipped</td><td>1 failed</td><td>55m 9s 553ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3"><strong>End-to-End (E2E) Test Report</strong></td></tr><tr><td colspan="3"><em>e2e/exercise/programming/ProgrammingExerciseStaticCodeAnalysis.spec.ts</em></td></tr><tr><td>ts.Static code analysis tests › Configures SCA grading and makes a successful submission with SCA errors</td><td>❌ failure</td><td>1m 48s 261ms</td></tr></table>
author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-22T12:00:59Z
body: ### End-to-End (E2E) Test Results Summary

<table><tr><th><th>Tests</th><th>Passed ☑️</th><th>Skipped ⚠️</th><th>Failed ❌️</th><th>Time ⏱</th></tr><tr><td>End-to-End (E2E) Test Report</td><td>201 ran</td><td>197 passed</td><td>3 skipped</td><td>1 failed</td><td>57m 16s 838ms</td></tr></table> <table><tr><th>Test</th><th>Result</th><th>Time ⏱</th></tr><tr><td colspan="3"><strong>End-to-End (E2E) Test Report</strong></td></tr><tr><td colspan="3"><em>e2e/exercise/programming/ProgrammingExerciseStaticCodeAnalysis.spec.ts</em></td></tr><tr><td>ts.Static code analysis tests › Configures SCA grading and makes a successful submission with SCA errors</td><td>❌ failure</td><td>1m 49s 292ms</td></tr></table>
author_association: NONE