@@ -271,14 +271,17 @@ func (s *APIV1Service) SignOut(ctx context.Context, _ *v1pb.SignOutRequest) (*em
271271
272272// RefreshToken exchanges a valid refresh token for a new access token.
273273//
274- // This endpoint:
274+ // This endpoint implements refresh token rotation with sliding window sessions :
275275// 1. Extracts the refresh token from the HttpOnly cookie (memos_refresh)
276276// 2. Validates the refresh token against the database (checking expiry and revocation)
277- // 3. Generates a new short-lived access token (15 minutes)
278- // 4. Returns the new access token and its expiry time
277+ // 3. Rotates the refresh token: generates a new one with fresh 30-day expiry
278+ // 4. Generates a new short-lived access token (15 minutes)
279+ // 5. Sets the new refresh token as HttpOnly cookie
280+ // 6. Returns the new access token and its expiry time
279281//
280- // The refresh token remains valid and is not rotated.
281- // Client should store the new access token in memory and use it for API requests.
282+ // Token rotation provides:
283+ // - Sliding window sessions: active users stay logged in indefinitely
284+ // - Better security: stolen refresh tokens become invalid after legitimate refresh
282285//
283286// Authentication: Requires valid refresh token in cookie (public endpoint)
284287// Returns: New access token and expiry timestamp.
@@ -295,13 +298,46 @@ func (s *APIV1Service) RefreshToken(ctx context.Context, _ *v1pb.RefreshTokenReq
295298 return nil , status .Errorf (codes .Unauthenticated , "refresh token not found" )
296299 }
297300
298- // Validate refresh token
301+ // Validate refresh token and get old token ID for rotation
299302 authenticator := auth .NewAuthenticator (s .Store , s .Secret )
300- user , _ , err := authenticator .AuthenticateByRefreshToken (ctx , refreshToken )
303+ user , oldTokenID , err := authenticator .AuthenticateByRefreshToken (ctx , refreshToken )
301304 if err != nil {
302305 return nil , status .Errorf (codes .Unauthenticated , "invalid refresh token: %v" , err )
303306 }
304307
308+ // --- Refresh Token Rotation ---
309+ // Generate new refresh token with fresh 30-day expiry (sliding window)
310+ newTokenID := util .GenUUID ()
311+ newRefreshToken , newRefreshExpiresAt , err := auth .GenerateRefreshToken (user .ID , newTokenID , []byte (s .Secret ))
312+ if err != nil {
313+ return nil , status .Errorf (codes .Internal , "failed to generate refresh token: %v" , err )
314+ }
315+
316+ // Store new refresh token (add before remove to handle race conditions)
317+ clientInfo := s .extractClientInfo (ctx )
318+ newRefreshTokenRecord := & storepb.RefreshTokensUserSetting_RefreshToken {
319+ TokenId : newTokenID ,
320+ ExpiresAt : timestamppb .New (newRefreshExpiresAt ),
321+ CreatedAt : timestamppb .Now (),
322+ ClientInfo : clientInfo ,
323+ }
324+ if err := s .Store .AddUserRefreshToken (ctx , user .ID , newRefreshTokenRecord ); err != nil {
325+ return nil , status .Errorf (codes .Internal , "failed to store refresh token: %v" , err )
326+ }
327+
328+ // Remove old refresh token
329+ if err := s .Store .RemoveUserRefreshToken (ctx , user .ID , oldTokenID ); err != nil {
330+ // Log but don't fail - old token will expire naturally
331+ slog .Warn ("failed to remove old refresh token" , "error" , err , "userID" , user .ID , "tokenID" , oldTokenID )
332+ }
333+
334+ // Set new refresh token cookie
335+ newRefreshCookie := s .buildRefreshTokenCookie (ctx , newRefreshToken , newRefreshExpiresAt )
336+ if err := SetResponseHeader (ctx , "Set-Cookie" , newRefreshCookie ); err != nil {
337+ return nil , status .Errorf (codes .Internal , "failed to set refresh token cookie: %v" , err )
338+ }
339+ // --- End Rotation ---
340+
305341 // Generate new access token
306342 accessToken , expiresAt , err := auth .GenerateAccessTokenV2 (
307343 user .ID ,
0 commit comments