Skip to content

Commit c2aea5a

Browse files
committed
feat: implement refresh token rotation with sliding window sessions in the auth service
1 parent d55af9b commit c2aea5a

File tree

1 file changed

+43
-7
lines changed

1 file changed

+43
-7
lines changed

server/router/api/v1/auth_service.go

Lines changed: 43 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)