feat: implement refresh token rotation with sliding window sessions in the auth service

This commit is contained in:
Johnny 2025-12-30 23:00:15 +08:00
parent d55af9b527
commit c2aea5a4b7
1 changed files with 43 additions and 7 deletions

View File

@ -271,14 +271,17 @@ func (s *APIV1Service) SignOut(ctx context.Context, _ *v1pb.SignOutRequest) (*em
// RefreshToken exchanges a valid refresh token for a new access token.
//
// This endpoint:
// This endpoint implements refresh token rotation with sliding window sessions:
// 1. Extracts the refresh token from the HttpOnly cookie (memos_refresh)
// 2. Validates the refresh token against the database (checking expiry and revocation)
// 3. Generates a new short-lived access token (15 minutes)
// 4. Returns the new access token and its expiry time
// 3. Rotates the refresh token: generates a new one with fresh 30-day expiry
// 4. Generates a new short-lived access token (15 minutes)
// 5. Sets the new refresh token as HttpOnly cookie
// 6. Returns the new access token and its expiry time
//
// The refresh token remains valid and is not rotated.
// Client should store the new access token in memory and use it for API requests.
// Token rotation provides:
// - Sliding window sessions: active users stay logged in indefinitely
// - Better security: stolen refresh tokens become invalid after legitimate refresh
//
// Authentication: Requires valid refresh token in cookie (public endpoint)
// Returns: New access token and expiry timestamp.
@ -295,13 +298,46 @@ func (s *APIV1Service) RefreshToken(ctx context.Context, _ *v1pb.RefreshTokenReq
return nil, status.Errorf(codes.Unauthenticated, "refresh token not found")
}
// Validate refresh token
// Validate refresh token and get old token ID for rotation
authenticator := auth.NewAuthenticator(s.Store, s.Secret)
user, _, err := authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
user, oldTokenID, err := authenticator.AuthenticateByRefreshToken(ctx, refreshToken)
if err != nil {
return nil, status.Errorf(codes.Unauthenticated, "invalid refresh token: %v", err)
}
// --- Refresh Token Rotation ---
// Generate new refresh token with fresh 30-day expiry (sliding window)
newTokenID := util.GenUUID()
newRefreshToken, newRefreshExpiresAt, err := auth.GenerateRefreshToken(user.ID, newTokenID, []byte(s.Secret))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to generate refresh token: %v", err)
}
// Store new refresh token (add before remove to handle race conditions)
clientInfo := s.extractClientInfo(ctx)
newRefreshTokenRecord := &storepb.RefreshTokensUserSetting_RefreshToken{
TokenId: newTokenID,
ExpiresAt: timestamppb.New(newRefreshExpiresAt),
CreatedAt: timestamppb.Now(),
ClientInfo: clientInfo,
}
if err := s.Store.AddUserRefreshToken(ctx, user.ID, newRefreshTokenRecord); err != nil {
return nil, status.Errorf(codes.Internal, "failed to store refresh token: %v", err)
}
// Remove old refresh token
if err := s.Store.RemoveUserRefreshToken(ctx, user.ID, oldTokenID); err != nil {
// Log but don't fail - old token will expire naturally
slog.Warn("failed to remove old refresh token", "error", err, "userID", user.ID, "tokenID", oldTokenID)
}
// Set new refresh token cookie
newRefreshCookie := s.buildRefreshTokenCookie(ctx, newRefreshToken, newRefreshExpiresAt)
if err := SetResponseHeader(ctx, "Set-Cookie", newRefreshCookie); err != nil {
return nil, status.Errorf(codes.Internal, "failed to set refresh token cookie: %v", err)
}
// --- End Rotation ---
// Generate new access token
accessToken, expiresAt, err := auth.GenerateAccessTokenV2(
user.ID,